Compare commits
10 Commits
47e481a461
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 914108e575 | |||
| ba836f00e6 | |||
| afefba3184 | |||
| aba394b84d | |||
| 71430bc3b0 | |||
| 6ff8fb5c25 | |||
| 912ecdd9ed | |||
| 0a5a85e4c4 | |||
| 90669c5fd6 | |||
| 6e51ffc5e1 |
@@ -5,3 +5,10 @@ interfaces/
|
||||
synthetic.json
|
||||
uopi.toml
|
||||
*.local.toml
|
||||
resources
|
||||
.claude
|
||||
.github
|
||||
.goreleaser.yaml
|
||||
.iocsh_history
|
||||
go.work.sum
|
||||
*.log
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all frontend backend backend-epics backend-debug release release-epics test bench race lint clean run
|
||||
.PHONY: all frontend backend backend-debug release catools test bench race lint clean run
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sources — adding any file here triggers a frontend or backend rebuild #
|
||||
@@ -8,7 +8,9 @@ FRONTEND_SRCS := $(shell find web/src -name '*.tsx' -o -name '*.ts' -o -name '*.
|
||||
$(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
|
||||
tools/buildfrontend/main.go
|
||||
|
||||
GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
|
||||
GO_SRCS := $(shell find cmd internal -name '*.go') \
|
||||
$(shell find pkg/ca -name '*.go') \
|
||||
web/embed.go go.mod go.sum go.work
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Outputs #
|
||||
@@ -16,16 +18,17 @@ GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
|
||||
|
||||
FRONTEND_OUT := web/dist/main.js
|
||||
BINARY := dist/uopi
|
||||
CATOOLS := dist/catools
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Top-level targets #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
all: $(BINARY)
|
||||
all: $(BINARY) $(CATOOLS)
|
||||
|
||||
frontend: $(FRONTEND_OUT)
|
||||
|
||||
backend: $(BINARY)
|
||||
backend: $(BINARY) $(CATOOLS)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build rules #
|
||||
@@ -36,127 +39,40 @@ $(FRONTEND_OUT): $(FRONTEND_SRCS)
|
||||
@mkdir -p web/dist
|
||||
go run ./tools/buildfrontend/main.go
|
||||
|
||||
# Compile the self-contained binary (embeds web/dist at build time)
|
||||
# Compile the self-contained binary (embeds web/dist at build time).
|
||||
# Pure-Go CA client (pkg/ca) is always used; CGO_ENABLED=0 produces a fully
|
||||
# static binary with no external library dependencies.
|
||||
$(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
|
||||
@mkdir -p dist
|
||||
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||
|
||||
# Build with EPICS Channel Access support.
|
||||
#
|
||||
# Auto-detection: if EPICS_BASE is set in the environment, CGO flags are
|
||||
# derived automatically. Override any variable on the command line:
|
||||
#
|
||||
# make backend-epics EPICS_BASE=/path/to/epics/base EPICS_ARCH=linux-x86_64
|
||||
#
|
||||
# If EPICS_BASE is not set, set it to the directory that contains
|
||||
# include/cadef.h and lib/<arch>/libca.so.
|
||||
|
||||
EPICS_BASE ?= $(EPICS_BASE)
|
||||
|
||||
# Detect host arch from EPICS startup script if not provided.
|
||||
EPICS_ARCH ?= $(shell \
|
||||
if [ -x "$(EPICS_BASE)/startup/EpicsHostArch" ]; then \
|
||||
"$(EPICS_BASE)/startup/EpicsHostArch"; \
|
||||
elif [ -x "$(EPICS_BASE)/lib" ]; then \
|
||||
ls "$(EPICS_BASE)/lib" | grep linux | head -1; \
|
||||
else \
|
||||
echo "linux-x86_64"; \
|
||||
fi)
|
||||
|
||||
EPICS_CGO_CFLAGS ?= -I$(EPICS_BASE)/include -I$(EPICS_BASE)/include/os/Linux -I$(EPICS_BASE)/include/compiler/gcc
|
||||
EPICS_CGO_LDFLAGS ?= -L$(EPICS_BASE)/lib/$(EPICS_ARCH) -lca -lCom -Wl,-rpath,$(EPICS_BASE)/lib/$(EPICS_ARCH)
|
||||
|
||||
backend-epics: $(FRONTEND_OUT)
|
||||
@if [ -z "$(EPICS_BASE)" ]; then \
|
||||
echo "ERROR: EPICS_BASE is not set."; \
|
||||
echo " export EPICS_BASE=/path/to/epics/base"; \
|
||||
echo " then re-run: make backend-epics"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building with EPICS support"
|
||||
@echo " EPICS_BASE = $(EPICS_BASE)"
|
||||
@echo " EPICS_ARCH = $(EPICS_ARCH)"
|
||||
# CA diagnostic tools (caget/caput/cainfo/camonitor equivalents)
|
||||
$(CATOOLS): $(GO_SRCS)
|
||||
@mkdir -p dist
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
|
||||
CGO_LDFLAGS="$(EPICS_CGO_LDFLAGS)" \
|
||||
go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||
@echo "Built: $(BINARY) (EPICS CA enabled)"
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||
|
||||
# Unstripped binary for debugging
|
||||
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
|
||||
backend-debug: $(FRONTEND_OUT)
|
||||
@mkdir -p dist
|
||||
go build -o $(BINARY) ./cmd/uopi
|
||||
CGO_ENABLED=0 go build -o $(BINARY) ./cmd/uopi
|
||||
CGO_ENABLED=0 go build -o $(CATOOLS) ./cmd/catools
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Portable release targets #
|
||||
# Release #
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Two flavours depending on whether EPICS is needed:
|
||||
#
|
||||
# make release — pure-Go, CGO_ENABLED=0, 100 % static binary.
|
||||
# No EPICS; works anywhere without extra libs.
|
||||
#
|
||||
# make release-epics — CGO build with EPICS CA.
|
||||
# Tries to link libca/libCom statically (if .a
|
||||
# archives are present in $(EPICS_LIBDIR)).
|
||||
# Falls back to bundled shared libs + $ORIGIN
|
||||
# rpath when only .so files are available.
|
||||
# The output directory (dist/release/) contains
|
||||
# everything needed to run on the target machine.
|
||||
#
|
||||
# Cross-compilation (no-EPICS only): override RELEASE_OS / RELEASE_ARCH.
|
||||
# make release RELEASE_OS=linux RELEASE_ARCH=arm64
|
||||
# Fully static, pure-Go CA client, no external library dependencies.
|
||||
# Cross-compile: make release RELEASE_OS=linux RELEASE_ARCH=arm64
|
||||
|
||||
RELEASE_OS ?= linux
|
||||
RELEASE_ARCH ?= amd64
|
||||
RELEASE_DIR := dist/release
|
||||
EPICS_LIBDIR := $(EPICS_BASE)/lib/$(EPICS_ARCH)
|
||||
|
||||
# Fully static, no CGO — the most portable build possible.
|
||||
release: $(FRONTEND_OUT)
|
||||
@mkdir -p $(RELEASE_DIR)
|
||||
CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \
|
||||
go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi
|
||||
@echo "Built: $(RELEASE_DIR)/uopi (fully static, no EPICS)"
|
||||
|
||||
# EPICS release — maximum static linking.
|
||||
# Strategy A (preferred): if libca.a + libCom.a exist, embed them directly
|
||||
# → single self-contained binary, no extra files needed.
|
||||
# Strategy B (fallback): copy the shared libs next to the binary and set an
|
||||
# $ORIGIN rpath so the OS finds them regardless of installation prefix.
|
||||
release-epics: $(FRONTEND_OUT)
|
||||
@if [ -z "$(EPICS_BASE)" ]; then \
|
||||
echo "ERROR: EPICS_BASE is not set."; \
|
||||
echo " export EPICS_BASE=/path/to/epics/base"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@mkdir -p $(RELEASE_DIR)
|
||||
@echo "Building portable EPICS release"
|
||||
@echo " EPICS_BASE = $(EPICS_BASE)"
|
||||
@echo " EPICS_ARCH = $(EPICS_ARCH)"
|
||||
@if [ -f "$(EPICS_LIBDIR)/libca.a" ] && [ -f "$(EPICS_LIBDIR)/libCom.a" ]; then \
|
||||
echo " Strategy A: static .a archives found — embedding libca + libCom"; \
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
|
||||
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
|
||||
CGO_LDFLAGS="-Wl,-Bstatic -L$(EPICS_LIBDIR) -lca -lCom -Wl,-Bdynamic -lm -lpthread -lrt -ldl" \
|
||||
go build -tags epics \
|
||||
-ldflags="-s -w -linkmode external" \
|
||||
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
|
||||
echo "Built: $(RELEASE_DIR)/uopi (EPICS statically embedded)"; \
|
||||
else \
|
||||
echo " Strategy B: no .a archives — bundling shared libs with \$$ORIGIN rpath"; \
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
|
||||
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
|
||||
CGO_LDFLAGS="-L$(EPICS_LIBDIR) -lca -lCom -Wl,-rpath,\$$ORIGIN" \
|
||||
go build -tags epics \
|
||||
-ldflags="-s -w" \
|
||||
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
|
||||
cp $(EPICS_LIBDIR)/libca.so* $(RELEASE_DIR)/; \
|
||||
cp $(EPICS_LIBDIR)/libCom.so* $(RELEASE_DIR)/; \
|
||||
echo "Built: $(RELEASE_DIR)/ (deploy the whole directory together)"; \
|
||||
ls $(RELEASE_DIR)/; \
|
||||
fi
|
||||
@echo "Built: $(RELEASE_DIR)/uopi (static, pure-Go CA)"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dev / CI #
|
||||
@@ -164,10 +80,12 @@ release-epics: $(FRONTEND_OUT)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd pkg/ca && go test ./...
|
||||
|
||||
# Run tests with the race detector enabled.
|
||||
race:
|
||||
go test -race ./...
|
||||
cd pkg/ca && go test -race ./...
|
||||
|
||||
# Run all benchmarks and print memory allocations.
|
||||
bench:
|
||||
@@ -179,5 +97,7 @@ lint:
|
||||
run: $(BINARY)
|
||||
$(BINARY)
|
||||
|
||||
catools: $(CATOOLS)
|
||||
|
||||
clean:
|
||||
rm -rf dist/ web/dist/
|
||||
|
||||
@@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals |
|
||||
| **Live data** | EPICS Channel Access (CA) / PVAccess and user-defined synthetic signals |
|
||||
| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML |
|
||||
| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots |
|
||||
| **Widget library** | Text view/label, gauge, LED, multi-LED, bar, set-point control, button, image, link, plots |
|
||||
| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser |
|
||||
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
|
||||
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
|
||||
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
|
||||
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
|
||||
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
|
||||
| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) |
|
||||
| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
|
||||
| **Access control** | Identity via trusted proxy header; per-user global level (write/read-only/no-access); per-panel ownership + sharing; nested folders; optional logic-editor allowlist |
|
||||
| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
|
||||
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
|
||||
| **Observability** | Prometheus-format metrics at `/metrics` |
|
||||
@@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
|
||||
```toml
|
||||
[server]
|
||||
listen = ":8080"
|
||||
storage_dir = "./data" # where interface XML files and synthetic.json are stored
|
||||
storage_dir = "./interfaces" # where interface XML, ACL and logic data are stored
|
||||
|
||||
# Identity & access control (all optional)
|
||||
trusted_user_header = "" # HTTP header carrying the proxy-authenticated user
|
||||
default_user = "" # identity used when the header is absent (LAN/dev)
|
||||
# logic_editors = [] # restrict who may edit panel/control logic (users or groups)
|
||||
|
||||
# [[server.blacklist]] # downgrade a user: level = "readonly" or "noaccess"
|
||||
# user = "guest"
|
||||
# level = "readonly"
|
||||
|
||||
# [[groups]] # named user sets, referenced by panel sharing
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = false # requires build with -tags epics
|
||||
@@ -118,7 +136,16 @@ The REST API is available under `/api/v1`. Key endpoints:
|
||||
| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
|
||||
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface |
|
||||
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
|
||||
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
|
||||
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
|
||||
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
|
||||
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
|
||||
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
|
||||
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
|
||||
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
|
||||
| `GET` | `/metrics` | Prometheus-format server metrics |
|
||||
| `GET` | `/healthz` | Health check |
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Command catools provides CA (Channel Access) command-line utilities using
|
||||
// the pure-Go goca library.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// catools get <pv> [pv...] Get current value(s)
|
||||
// catools put <pv> <value> Write a value
|
||||
// catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||
// catools info <pv> [pv...] Print metadata (type, units, limits)
|
||||
//
|
||||
// Environment:
|
||||
//
|
||||
// EPICS_CA_ADDR_LIST — space-separated CA server addresses
|
||||
// EPICS_CA_AUTO_ADDR_LIST — set to "NO" to disable broadcast search
|
||||
// UOPI_CA_DEBUG — set to "1" for CA protocol debug logging
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
ca "github.com/uopi/goca"
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `catools — EPICS Channel Access command-line tool
|
||||
|
||||
Usage:
|
||||
catools get <pv> [pv...] Get current value(s)
|
||||
catools put <pv> <value> Write a value
|
||||
catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||
catools info <pv> [pv...] Print metadata (type, units, limits)
|
||||
|
||||
Environment:
|
||||
EPICS_CA_ADDR_LIST Space-separated CA server addresses
|
||||
EPICS_CA_AUTO_ADDR_LIST Set to NO to disable broadcast search
|
||||
UOPI_CA_DEBUG Set to 1 for CA protocol debug logging`)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := ca.ConfigFromEnv()
|
||||
cli, err := ca.NewClient(ctx, cfg)
|
||||
if err != nil {
|
||||
fatalf("connect: %v", err)
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "get":
|
||||
if len(args) == 0 {
|
||||
fatalf("get: no PV names specified")
|
||||
}
|
||||
runGet(ctx, cli, args)
|
||||
case "put":
|
||||
if len(args) < 2 {
|
||||
fatalf("put: usage: catools put <pv> <value>")
|
||||
}
|
||||
runPut(ctx, cli, args[0], args[1])
|
||||
case "info":
|
||||
if len(args) == 0 {
|
||||
fatalf("info: no PV names specified")
|
||||
}
|
||||
runInfo(ctx, cli, args)
|
||||
case "monitor":
|
||||
if len(args) == 0 {
|
||||
fatalf("monitor: no PV names specified")
|
||||
}
|
||||
runMonitor(ctx, cli, args)
|
||||
default:
|
||||
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// get //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runGet(ctx context.Context, cli *ca.Client, pvs []string) {
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, pv := range pvs {
|
||||
tv, err := cli.Get(tctx, pv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-30s %s\n", pv, formatValue(tv))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// put //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runPut(ctx context.Context, cli *ca.Client, pv, valueStr string) {
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get metadata to determine the right type.
|
||||
ci, err := cli.GetCtrl(tctx, pv)
|
||||
if err != nil {
|
||||
fatalf("%s: get metadata: %v", pv, err)
|
||||
}
|
||||
|
||||
var value any
|
||||
switch ci.DBFType {
|
||||
case proto.DBFString:
|
||||
value = valueStr
|
||||
case proto.DBFEnum:
|
||||
if n, err := strconv.ParseInt(valueStr, 10, 32); err == nil {
|
||||
value = int32(n)
|
||||
} else if ci.Enum != nil {
|
||||
for i, s := range ci.Enum.Strings {
|
||||
if strings.EqualFold(s, valueStr) {
|
||||
value = int32(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if value == nil {
|
||||
fatalf("%s: unknown enum value %q", pv, valueStr)
|
||||
}
|
||||
default:
|
||||
if n, err := strconv.ParseFloat(valueStr, 64); err == nil {
|
||||
value = n
|
||||
} else if n, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
|
||||
value = int64(n)
|
||||
} else {
|
||||
value = valueStr
|
||||
}
|
||||
}
|
||||
|
||||
if err := cli.Put(tctx, pv, value); err != nil {
|
||||
fatalf("%s: put: %v", pv, err)
|
||||
}
|
||||
|
||||
tv, err := cli.Get(tctx, pv)
|
||||
if err != nil {
|
||||
fmt.Printf("%-30s %s (write sent, readback failed: %v)\n", pv, valueStr, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-30s %s\n", pv, formatValue(tv))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// info //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runInfo(ctx context.Context, cli *ca.Client, pvs []string) {
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, pv := range pvs {
|
||||
ci, err := cli.GetCtrl(tctx, pv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err)
|
||||
continue
|
||||
}
|
||||
printInfo(pv, ci)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printInfo(pv string, ci ca.CtrlInfo) {
|
||||
fmt.Printf("Name : %s\n", pv)
|
||||
fmt.Printf("DBF type : %s (%d)\n", dbfTypeName(ci.DBFType), ci.DBFType)
|
||||
fmt.Printf("Count : %d\n", ci.Count)
|
||||
writable := "No"
|
||||
if ci.Access&proto.AccessWrite != 0 {
|
||||
writable = "Yes"
|
||||
}
|
||||
fmt.Printf("Writable : %s\n", writable)
|
||||
|
||||
switch {
|
||||
case ci.Double != nil:
|
||||
d := ci.Double
|
||||
fmt.Printf("Units : %s\n", d.Units)
|
||||
fmt.Printf("Disp range : [%g, %g]\n", d.LowerDispLimit, d.UpperDispLimit)
|
||||
fmt.Printf("Ctrl range : [%g, %g]\n", d.LowerCtrlLimit, d.UpperCtrlLimit)
|
||||
fmt.Printf("Alarm LOLO : %g HIHI: %g\n", d.LowerAlarmLimit, d.UpperAlarmLimit)
|
||||
fmt.Printf("Warn LOW : %g HIGH: %g\n", d.LowerWarnLimit, d.UpperWarnLimit)
|
||||
case ci.Long != nil:
|
||||
l := ci.Long
|
||||
fmt.Printf("Units : %s\n", l.Units)
|
||||
fmt.Printf("Disp range : [%d, %d]\n", l.LowerDispLimit, l.UpperDispLimit)
|
||||
fmt.Printf("Ctrl range : [%d, %d]\n", l.LowerCtrlLimit, l.UpperCtrlLimit)
|
||||
fmt.Printf("Alarm LOLO : %d HIHI: %d\n", l.LowerAlarmLimit, l.UpperAlarmLimit)
|
||||
fmt.Printf("Warn LOW : %d HIGH: %d\n", l.LowerWarnLimit, l.UpperWarnLimit)
|
||||
case ci.Enum != nil:
|
||||
fmt.Printf("Enum states: %d\n", len(ci.Enum.Strings))
|
||||
for i, s := range ci.Enum.Strings {
|
||||
fmt.Printf(" [%d] %s\n", i, s)
|
||||
}
|
||||
case ci.Str != nil:
|
||||
fmt.Printf("Type : string\n")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// monitor //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runMonitor(ctx context.Context, cli *ca.Client, pvs []string) {
|
||||
type entry struct {
|
||||
pv string
|
||||
ch chan proto.TimeValue
|
||||
can context.CancelFunc
|
||||
}
|
||||
|
||||
entries := make([]*entry, 0, len(pvs))
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, pv := range pvs {
|
||||
ch := make(chan proto.TimeValue, 32)
|
||||
cancelFn, err := cli.Subscribe(tctx, pv, ch)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: subscribe error: %v\n", pv, err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, &entry{pv: pv, ch: ch, can: cancelFn})
|
||||
fmt.Printf("Monitoring %s — press Ctrl-C to stop\n", pv)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
go func(e *entry) {
|
||||
for {
|
||||
select {
|
||||
case tv, ok := <-e.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ts := tv.Timestamp.Local().Format("15:04:05.000")
|
||||
fmt.Printf("%s %-30s %s\n", ts, e.pv, formatValue(tv))
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(e)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
for _, e := range entries {
|
||||
e.can()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Formatting helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func formatValue(tv proto.TimeValue) string {
|
||||
var val string
|
||||
switch {
|
||||
case tv.Str != "":
|
||||
val = tv.Str
|
||||
case len(tv.Doubles) > 1:
|
||||
parts := make([]string, len(tv.Doubles))
|
||||
for i, v := range tv.Doubles {
|
||||
parts[i] = strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
val = "[" + strings.Join(parts, " ") + "]"
|
||||
default:
|
||||
val = strconv.FormatFloat(tv.Double, 'g', -1, 64)
|
||||
}
|
||||
|
||||
severity := ""
|
||||
switch tv.Severity {
|
||||
case proto.SeverityMinor:
|
||||
severity = " MINOR"
|
||||
case proto.SeverityMajor:
|
||||
severity = " MAJOR"
|
||||
case proto.SeverityInvalid:
|
||||
severity = " INVALID"
|
||||
}
|
||||
|
||||
ts := tv.Timestamp.Local().Format("2006-01-02 15:04:05.000")
|
||||
return fmt.Sprintf("%s%s [%s]", val, severity, ts)
|
||||
}
|
||||
|
||||
func dbfTypeName(t int) string {
|
||||
switch t {
|
||||
case proto.DBFString:
|
||||
return "DBF_STRING"
|
||||
case proto.DBFShort:
|
||||
return "DBF_SHORT"
|
||||
case proto.DBFFloat:
|
||||
return "DBF_FLOAT"
|
||||
case proto.DBFEnum:
|
||||
return "DBF_ENUM"
|
||||
case proto.DBFChar:
|
||||
return "DBF_CHAR"
|
||||
case proto.DBFLong:
|
||||
return "DBF_LONG"
|
||||
case proto.DBFDouble:
|
||||
return "DBF_DOUBLE"
|
||||
default:
|
||||
return "DBF_UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "catools: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Command pvtools provides PV Access command-line utilities using
|
||||
// the pure-Go gopva library.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// pvtools get <pv> [pv...] Get current value(s) via PVA
|
||||
// pvtools put <pv> <value> Write a value via PVA
|
||||
// pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||
// pvtools info <pv> [pv...] Print PVA structure info
|
||||
//
|
||||
// Environment:
|
||||
//
|
||||
// EPICS_PVA_ADDR_LIST — space-separated PVA server addresses
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
pva "github.com/uopi/gopva"
|
||||
"github.com/uopi/gopva/pvdata"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `pvtools — EPICS PV Access command-line tool
|
||||
|
||||
Usage:
|
||||
pvtools get <pv> [pv...] Get current value(s)
|
||||
pvtools put <pv> <value> Write a value
|
||||
pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||
pvtools info <pv> [pv...] Print structure info
|
||||
|
||||
Environment:
|
||||
EPICS_PVA_ADDR_LIST Space-separated PVA server addresses`)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
switch cmd {
|
||||
case "get":
|
||||
if len(args) == 0 {
|
||||
fatalf("get: no PV names specified")
|
||||
}
|
||||
runGet(ctx, args)
|
||||
case "put":
|
||||
if len(args) < 2 {
|
||||
fatalf("put: usage: pvtools put <pv> <value>")
|
||||
}
|
||||
fatalf("put: PVA write not yet implemented")
|
||||
case "monitor":
|
||||
if len(args) == 0 {
|
||||
fatalf("monitor: no PV names specified")
|
||||
}
|
||||
runMonitor(ctx, args)
|
||||
case "info":
|
||||
if len(args) == 0 {
|
||||
fatalf("info: no PV names specified")
|
||||
}
|
||||
runInfo(ctx, args)
|
||||
default:
|
||||
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// get //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runGet(ctx context.Context, pvs []string) {
|
||||
cl, err := pva.NewClient()
|
||||
if err != nil {
|
||||
fatalf("connect: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, name := range pvs {
|
||||
sv, err := cl.Get(tctx, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-30s %s\n", name, formatValue(sv))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// monitor //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runMonitor(ctx context.Context, pvs []string) {
|
||||
cl, err := pva.NewClient()
|
||||
if err != nil {
|
||||
fatalf("connect: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
for _, name := range pvs {
|
||||
ch := cl.Monitor(ctx, name)
|
||||
go func(pvName string, ch <-chan pva.MonitorEvent) {
|
||||
for evt := range ch {
|
||||
if evt.Err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", pvName, evt.Err)
|
||||
return
|
||||
}
|
||||
ts := time.Now().Local().Format("15:04:05.000")
|
||||
fmt.Printf("%s %-30s %s\n", ts, pvName, formatValue(evt.Value))
|
||||
}
|
||||
}(name, ch)
|
||||
fmt.Printf("Monitoring %s (PVA) — press Ctrl-C to stop\n", name)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// info //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func runInfo(ctx context.Context, pvs []string) {
|
||||
cl, err := pva.NewClient()
|
||||
if err != nil {
|
||||
fatalf("connect: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, name := range pvs {
|
||||
sv, err := cl.Get(tctx, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
printInfo(name, sv)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printInfo(name string, sv pvdata.StructValue) {
|
||||
fmt.Printf("Name : %s\n", name)
|
||||
fmt.Printf("Fields : %d\n", len(sv.Fields))
|
||||
printStruct(sv, " ")
|
||||
}
|
||||
|
||||
func printStruct(sv pvdata.StructValue, indent string) {
|
||||
for _, f := range sv.Fields {
|
||||
switch v := f.Value.(type) {
|
||||
case pvdata.StructValue:
|
||||
fmt.Printf("%s%s (struct):\n", indent, f.Name)
|
||||
printStruct(v, indent+" ")
|
||||
default:
|
||||
fmt.Printf("%s%s = %v\n", indent, f.Name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Formatting helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func formatValue(sv pvdata.StructValue) string {
|
||||
if len(sv.Fields) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
val := fieldByName(sv, "value")
|
||||
alarm := ""
|
||||
if a := structByName(sv, "alarm"); a != nil {
|
||||
if sev := fieldByName(*a, "severity"); sev != nil {
|
||||
if s, ok := sev.(int32); ok && s != 0 {
|
||||
alarm = fmt.Sprintf(" (alarm sev=%d)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
ts := ""
|
||||
if t := structByName(sv, "timeStamp"); t != nil {
|
||||
sec := int64(0)
|
||||
nsec := int32(0)
|
||||
if v := fieldByName(*t, "secondsPastEpoch"); v != nil {
|
||||
sec, _ = v.(int64)
|
||||
}
|
||||
if v := fieldByName(*t, "nanoseconds"); v != nil {
|
||||
nsec, _ = v.(int32)
|
||||
}
|
||||
if sec != 0 {
|
||||
const epicsEpoch = 631152000
|
||||
ut := time.Unix(sec+epicsEpoch, int64(nsec)).Local()
|
||||
ts = " [" + ut.Format("2006-01-02 15:04:05.000") + "]"
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
val = sv.Fields[0].Value
|
||||
}
|
||||
return fmt.Sprintf("%v%s%s", formatScalar(val), alarm, ts)
|
||||
}
|
||||
|
||||
func formatScalar(v any) string {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(x), 'g', -1, 32)
|
||||
case string:
|
||||
return x
|
||||
case []string:
|
||||
return strings.Join(x, " ")
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func fieldByName(sv pvdata.StructValue, name string) interface{} {
|
||||
for _, f := range sv.Fields {
|
||||
if f.Name == name {
|
||||
return f.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
|
||||
for _, f := range sv.Fields {
|
||||
if f.Name == name {
|
||||
if s, ok := f.Value.(pvdata.StructValue); ok {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "pvtools: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+57
-5
@@ -11,11 +11,15 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/config"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
"github.com/uopi/uopi/internal/datasource/pva"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/server"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
"github.com/uopi/uopi/web"
|
||||
@@ -48,6 +52,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
aclStore, err := panelacl.New(cfg.Server.StorageDir)
|
||||
if err != nil {
|
||||
log.Error("failed to open panel ACL store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
webFS, err := fs.Sub(web.FS, "dist")
|
||||
if err != nil {
|
||||
log.Error("failed to sub web dist", "err", err)
|
||||
@@ -57,7 +67,7 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))
|
||||
|
||||
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
|
||||
// Enabled by default; disable via [datasource.stub] enabled = false in config.
|
||||
@@ -83,10 +93,18 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// EPICS Channel Access data source (requires -tags epics build).
|
||||
// epics.Available() returns false when built without the tag.
|
||||
// EPICS Channel Access data source.
|
||||
// The pure-Go implementation is used by default (no CGo required).
|
||||
// Build with -tags epics to use the CGo-based libca implementation instead.
|
||||
if cfg.Datasource.EPICS.Enabled && epics.Available() {
|
||||
ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL)
|
||||
ds := epics.New(
|
||||
cfg.Datasource.EPICS.CAAddrList,
|
||||
cfg.Datasource.EPICS.ArchiveURL,
|
||||
cfg.Datasource.EPICS.ChannelFinderURL,
|
||||
cfg.Datasource.EPICS.AutoSyncFilter,
|
||||
cfg.Datasource.EPICS.AutoSyncFromArchiver,
|
||||
cfg.Datasource.EPICS.PVNames,
|
||||
)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
log.Error("epics connect", "err", err)
|
||||
} else {
|
||||
@@ -94,7 +112,41 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log)
|
||||
// PV Access data source.
|
||||
if cfg.Datasource.PVA.Enabled {
|
||||
pvaDS := pva.New(cfg.Datasource.PVA.AddrList)
|
||||
if err := pvaDS.Connect(ctx); err != nil {
|
||||
log.Error("pva connect", "err", err)
|
||||
} else {
|
||||
brk.Register(pvaDS)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the global access policy from config: every user is trusted with
|
||||
// full write access unless downgraded by the blacklist; groups are named
|
||||
// sets of users referenced by per-panel sharing.
|
||||
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
|
||||
for _, e := range cfg.Server.Blacklist {
|
||||
blacklist[e.User] = e.Level
|
||||
}
|
||||
groups := make(map[string][]string, len(cfg.Groups))
|
||||
for _, g := range cfg.Groups {
|
||||
groups[g.Name] = g.Members
|
||||
}
|
||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
|
||||
|
||||
// Server-side control logic: flow graphs that run continuously under the root
|
||||
// context, independent of any panel. The store is loaded from disk and the
|
||||
// engine starts all enabled graphs.
|
||||
ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
|
||||
if err != nil {
|
||||
log.Error("failed to open control logic store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
|
||||
ctrlEngine.Reload()
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
||||
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
|
||||
+211
-66
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
|
||||
|
||||
## 2. Users and Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
|
||||
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
|
||||
| Administrator | Manages server configuration, data sources, and saved interfaces |
|
||||
User identity is established from a header set by a trusted authenticating reverse
|
||||
proxy (`server.trusted_user_header`), with a configurable `default_user` fallback for
|
||||
unproxied/LAN deployments. There is no login page inside the application.
|
||||
|
||||
In the initial version all users share the same access level. Role-based access control is deferred to a future release.
|
||||
**Global access levels.** Every user is trusted with full **write** access by default. A
|
||||
configuration blacklist can downgrade specific users to **read-only** (view only, no
|
||||
writes) or **no access** (denied). Named **groups** are defined in configuration and
|
||||
referenced by per-panel sharing.
|
||||
|
||||
**Per-panel access.** Panels are owned by their creator and private by default. Owners
|
||||
share a panel with specific users/groups (read or write) or make it public; the owner's
|
||||
global level always caps the per-panel permission. Panels are organised into nested
|
||||
folders whose permissions inherit down the chain. See §9.
|
||||
|
||||
**Logic-edit restriction.** Adding/editing panel logic and server-side control logic can
|
||||
optionally be limited to an allowlist of users/groups (`server.logic_editors`); when
|
||||
unset, any writer may edit logic.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,58 +34,107 @@ In the initial version all users share the same access level. Role-based access
|
||||
|
||||
The default mode when opening the application.
|
||||
|
||||
**Interface list pane (left, collapsible)**
|
||||
- Displays all interfaces saved on the server, grouped into a tree by folder.
|
||||
**Interface list pane (left, collapsible, resizable)**
|
||||
- Displays all interfaces saved on the server.
|
||||
- Right-click on an interface: options to open in Edit mode or clone it.
|
||||
- "New interface" button opens Edit mode with a blank canvas.
|
||||
- "Import" option loads an interface from a local XML file.
|
||||
- The pane width can be adjusted by dragging the resize handle on its right edge.
|
||||
|
||||
**HMI canvas (center)**
|
||||
- Renders the selected interface as a live, interactive panel.
|
||||
- Widgets display real-time data; controls (set-value, buttons) are active.
|
||||
- Panel logic (if any) runs while the panel is open (see §6).
|
||||
- No drag, resize, or layout operations are possible in this mode.
|
||||
- Right-clicking any widget opens a context menu:
|
||||
- **Signal info** — floating window showing DS name, type, unit, range, current value and timestamp.
|
||||
- **Signal info** — shows DS name, type, unit, range, current value and timestamp.
|
||||
- **Copy signal name** — copies the signal identifier to the clipboard.
|
||||
- **Export data to CSV** — downloads buffered/historical data for the signal(s) used by the widget.
|
||||
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
|
||||
|
||||
> Dedicated multi-signal plotting is provided by **plot panels** — a special interface
|
||||
> kind whose plots fill the viewport (see §3.3) — rather than a separate live "Plot tab".
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide interface list pane.
|
||||
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas).
|
||||
- "Live" button: return to real-time data after historical navigation.
|
||||
- **⏱ History** button: toggle historical time navigation bar.
|
||||
- **⚙ Control logic** button: open the server-side control-logic editor (see §7).
|
||||
Shown only to users permitted to edit logic.
|
||||
- Zoom control (A− / % / A+): adjust the UI scale (see §3.4).
|
||||
- Edit button: switch to Edit mode for the current interface.
|
||||
- Connection status indicator and signed-in user chip.
|
||||
|
||||
**Historical time navigation bar** (shown when History is active)
|
||||
- Date/time range pickers (start and end).
|
||||
- **Load** button fetches archive data and replaces live data in all widgets.
|
||||
- **Live** button discards archive data and resumes real-time streaming.
|
||||
- Status shown on plot widgets: "Loading history…", "No archive data for this range", "Archive unavailable".
|
||||
|
||||
### 3.2 Edit Mode
|
||||
|
||||
Activated via the "New interface" button or by right-clicking an existing interface.
|
||||
Activated via the "New interface" button or by clicking Edit in the toolbar.
|
||||
|
||||
**Signal tree pane (left, resizable and collapsible)**
|
||||
- Shows all signals known to each connected data source.
|
||||
- Sources are shown as top-level nodes; signals are nested within.
|
||||
- User can add custom entries:
|
||||
- For EPICS: manually enter a PV name.
|
||||
- For Synthetic: define a new synthetic signal (see §5.2).
|
||||
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
|
||||
- For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
|
||||
- Filter/search box to narrow the list.
|
||||
- Synthetic signals show an edit (✎) button to reopen the wizard.
|
||||
|
||||
**Widget canvas (center)**
|
||||
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
|
||||
- Background grid with optional snap-to-grid.
|
||||
**Center area — Layout / Logic tabs**
|
||||
- **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
|
||||
over a background grid with snap-to-grid. (For plot panels this is replaced by the
|
||||
split-layout editor; see §3.3.)
|
||||
- **Logic**: a node-graph flow editor for panel behaviour (see §6). Hidden when the user
|
||||
is not permitted to edit logic.
|
||||
|
||||
**Properties pane (right, collapsible)**
|
||||
**Local variables**
|
||||
- A panel may declare panel-scoped variables (data source `local`) with initial values.
|
||||
- They are written by set-value widgets, buttons and logic actions, and referenced
|
||||
anywhere a signal is. Added from the signal tree's *Local* group / the Logic palette.
|
||||
|
||||
**Properties pane (right, resizable and collapsible)**
|
||||
- Appears when one or more widgets are selected.
|
||||
- Displays and edits all options for the selected widget (see §4).
|
||||
- Width is adjustable by dragging the resize handle on its left edge.
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide signal pane.
|
||||
- Show/hide properties pane.
|
||||
- Show/hide signal pane / properties pane.
|
||||
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
|
||||
- Import / Export interface to/from local XML file.
|
||||
- Save interface to server.
|
||||
- Load interface from server.
|
||||
- Export interface to local XML file.
|
||||
- Import interface from local XML file.
|
||||
- "Add text" tool — inserts a static text label.
|
||||
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
|
||||
- "Add link" tool — inserts a button that opens another interface.
|
||||
- Close (return to View mode).
|
||||
- Zoom control (A− / % / A+).
|
||||
|
||||
### 3.3 Plot Panels (Split Layout)
|
||||
|
||||
A **plot panel** is a special interface kind dedicated to charts. Rather than free-form
|
||||
widget boxes, plots **fill the viewport** and the user divides the space between them with
|
||||
a recursive split layout (tmux/IDE-style tiling). Created via **+ Plot** in the interface
|
||||
list, opening with a single full-viewport empty plot.
|
||||
|
||||
**Editing (Edit mode):**
|
||||
- Hover a pane and use its split buttons (**⬌** vertical / **⬍** horizontal) to divide it;
|
||||
a new empty plot fills the freed half. Nesting is unlimited.
|
||||
- Drag the divider between two panes to resize them.
|
||||
- Click a pane to select it and configure its plot in the Properties pane (plot sub-type,
|
||||
Y range, time window, legend, per-signal colour). Drag a signal onto a pane to add it.
|
||||
- A pane's **✕** removes it; the layout collapses onto its sibling.
|
||||
|
||||
**View mode:** the saved split layout fills the screen with live, streaming plots; the
|
||||
per-widget right-click menu (signal info / copy / CSV) and historical time navigation
|
||||
apply as for any time-series plot.
|
||||
|
||||
Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and
|
||||
options are available within a pane.
|
||||
|
||||
### 3.4 UI Zoom
|
||||
|
||||
The toolbar in both modes contains a zoom control (**A−** / **nn%** / **A+**) that adjusts the base font size of the entire UI:
|
||||
|
||||
- 11 zoom steps from 50% to 250% (50, 60, 75, 85, 100, 115, 130, 150, 175, 200, 250%).
|
||||
- The selected zoom level is persisted in `localStorage` and restored on the next page load.
|
||||
- At 100%, the base font size adapts to viewport height via `clamp(13px, 1.5vh, 18px)`, making the UI naturally usable on 4K displays without any manual adjustment.
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +142,7 @@ Activated via the "New interface" button or by right-clicking an existing interf
|
||||
|
||||
### 4.1 Creating Widgets
|
||||
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size.
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type. The user selects one and the widget is placed at the drop location with default size.
|
||||
|
||||
### 4.2 Selecting Widgets
|
||||
|
||||
@@ -104,8 +163,7 @@ When multiple widgets are selected:
|
||||
- An align/distribute toolbar appears above the canvas with:
|
||||
- Align left / center horizontal / right.
|
||||
- Align top / center vertical / bottom.
|
||||
- Distribute evenly — by center spacing (horizontal/vertical).
|
||||
- Distribute evenly — by gap size (horizontal/vertical).
|
||||
- Distribute evenly (horizontal/vertical).
|
||||
|
||||
### 4.4 Widget Catalogue
|
||||
|
||||
@@ -115,7 +173,7 @@ When multiple widgets are selected:
|
||||
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
|
||||
| Vertical bar | numeric scalar | Vertical level indicator |
|
||||
| Horizontal bar | numeric scalar | Horizontal level indicator |
|
||||
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button |
|
||||
| Set value | numeric, string, or enum; writable | Input field or enum dropdown + Set button |
|
||||
| LED | boolean / numeric | Coloured indicator with configurable condition and label |
|
||||
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
|
||||
| Button | writable | Sends a fixed value or command on click |
|
||||
@@ -140,67 +198,153 @@ When multiple widgets are selected:
|
||||
Common to all:
|
||||
- Label text, font size, text colour.
|
||||
- Position (X, Y) and size (W, H) — editable numerically.
|
||||
- Data source and signal name (read-only after creation; reassignable via drag).
|
||||
- Data source and signal name.
|
||||
|
||||
Per type:
|
||||
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
|
||||
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states, label.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual min/max), time window duration, legend position (top / bottom / none), value format string.
|
||||
- **Set value**: no special options — enum mode is detected automatically from signal metadata.
|
||||
- **Link**: target interface name.
|
||||
|
||||
### 4.6 Set-value Widget — Enum Mode
|
||||
|
||||
When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
|
||||
- The input field is replaced by a `<select>` dropdown showing all enum options.
|
||||
- The current live value is shown as the pre-selected option (display only).
|
||||
- The user selects a value from the dropdown, then clicks **Set** to write it.
|
||||
- The Set button is always required; changing the dropdown does not write immediately.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Sources
|
||||
|
||||
### 5.1 EPICS
|
||||
|
||||
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode.
|
||||
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
|
||||
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user.
|
||||
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
|
||||
- Connects to an EPICS environment via Channel Access (CA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range, alarm limits, enum strings (for mbbi/mbbo records), read/write mode.
|
||||
- Uses `ca_add_event` monitors — no polling.
|
||||
- When an EPICS Archive Appliance is configured, the server can satisfy historical data requests from the toolbar time navigator.
|
||||
|
||||
### 5.2 Synthetic
|
||||
### 5.2 Synthetic Signals
|
||||
|
||||
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions.
|
||||
A signal defined by composing one or more input signals through a chain of processing nodes.
|
||||
|
||||
**Built-in processing functions (non-exhaustive):**
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide.
|
||||
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
|
||||
- FFT, inverse FFT.
|
||||
- Peak detection, threshold crossing.
|
||||
- Custom formula: inline expression (`a * sin(b) + c`).
|
||||
- Lua script block: arbitrary Lua code with access to input values and state.
|
||||
**Two authoring surfaces:**
|
||||
- **Wizard** — for the common single-input case: name the signal, pick an input and a
|
||||
processing node, set parameters, Create.
|
||||
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
|
||||
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
|
||||
|
||||
**User workflow:**
|
||||
1. Click "New synthetic signal" in the signal tree.
|
||||
2. Name the signal and choose input signals.
|
||||
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
|
||||
4. Optionally write a Lua snippet for custom logic.
|
||||
5. The synthetic signal appears in the tree and can be used like any other signal.
|
||||
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
|
||||
that created it), *user*, or *global* (shared with everyone).
|
||||
|
||||
The dialogs are resizable and default to a width that accommodates the Lua editor.
|
||||
|
||||
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the editor.
|
||||
|
||||
**Built-in node types:**
|
||||
|
||||
| Node | Parameters | Description |
|
||||
|------|------------|-------------|
|
||||
| Source | DS, signal name | Input from any data source |
|
||||
| Gain | factor | Multiply by constant |
|
||||
| Offset | value | Add constant |
|
||||
| Moving average | window (samples) | Rolling mean |
|
||||
| Low-pass filter | frequency (Hz), order (1–8) | IIR low-pass; correct for non-uniform sample rates |
|
||||
| Formula | expression | Inline math (variables: `a`, `b`, …) |
|
||||
| Lua script | script | Arbitrary Lua 5.1 with persistent state table |
|
||||
|
||||
**Lua editor:** the Lua node parameter uses a code editor with syntax highlighting (keywords, strings, comments, numbers rendered in distinct colours). The editor is a full-height multi-line input that grows with the dialog.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface Persistence
|
||||
## 6. Panel Logic
|
||||
|
||||
The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that
|
||||
gives a panel interactive behaviour. The flow runs **client-side** while the panel is open
|
||||
in View mode; it is saved as part of the interface XML. The tab and any logic editing are
|
||||
gated by the logic-edit restriction (§2).
|
||||
|
||||
**Authoring:** drag blocks from the palette (or click to add), connect output ports to
|
||||
input ports, and edit the selected node in the inspector. Expression fields reference a
|
||||
signal as `{ds:name}` and a panel-local variable by its bare name, and support arithmetic,
|
||||
comparisons, boolean logic and common math functions. The editor has its own undo/redo and
|
||||
copy/paste.
|
||||
|
||||
**Node categories:**
|
||||
|
||||
| Category | Nodes |
|
||||
|----------|-------|
|
||||
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
|
||||
| Logic | AND gate, If (then/else), Loop (count or while) |
|
||||
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays |
|
||||
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
|
||||
|
||||
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
|
||||
since the firing trigger last fired).
|
||||
|
||||
---
|
||||
|
||||
## 7. Control Logic
|
||||
|
||||
**Control logic** is server-side automation: flow graphs that run continuously on the
|
||||
server, independent of any connected client (unlike panel logic, which only runs while a
|
||||
panel is open). Opened with the **⚙ Control logic** button in the View-mode toolbar and
|
||||
managed through the REST API.
|
||||
|
||||
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
|
||||
- A **Lua** block provides custom logic; results are written back to signals.
|
||||
- Each graph can be enabled/disabled independently; saving reloads the engine live.
|
||||
- Editing is gated by the logic-edit restriction (§2).
|
||||
|
||||
---
|
||||
|
||||
## 8. Interface Persistence
|
||||
|
||||
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
||||
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
|
||||
- Saved versions are retained; a panel's version history can be listed, tagged and promoted.
|
||||
- Export/Import allows local file exchange of XML files.
|
||||
- The XML schema records: widget type, position, size, signal bindings, and all property values.
|
||||
- The XML schema records: interface kind (panel/plot) and split layout, widget type,
|
||||
position, size, signal bindings, all property values, local variables, and panel logic.
|
||||
|
||||
---
|
||||
|
||||
## 7. Historical Data Navigation
|
||||
## 9. Access Control, Sharing & Folders
|
||||
|
||||
When the server has archive access for all signals on the current canvas:
|
||||
- The top toolbar shows a date/time picker.
|
||||
- Selecting a past time replays data from the archive into all widgets.
|
||||
- The "Live" button resumes real-time streaming.
|
||||
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
|
||||
**Identity** is resolved per request from the trusted proxy header, falling back to
|
||||
`default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level,
|
||||
group memberships and whether they may edit logic; the UI hides affordances accordingly.
|
||||
|
||||
**Global levels** (§2): write (default), read-only, or no-access via the config blacklist.
|
||||
|
||||
**Per-panel sharing:** the **Share** dialog on a panel grants read or write to specific
|
||||
users or groups, or marks the panel public. New panels are private to their owner. The
|
||||
owner's global level caps any per-panel grant.
|
||||
|
||||
**Folders:** panels are organised into nested folders; permissions inherit down the folder
|
||||
chain. Panels can be dragged to reorder or move between folders.
|
||||
|
||||
**Logic-editor allowlist:** when `server.logic_editors` is set, only the listed users/
|
||||
groups may add or change panel logic and control logic; everyone else keeps full access to
|
||||
the rest of the application.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-functional Requirements
|
||||
## 10. Historical Data Navigation
|
||||
|
||||
When the server has archive access configured:
|
||||
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
|
||||
- Users set a start and end date/time and click **Load**.
|
||||
- Plot widgets fetch and display the archived data for the selected range.
|
||||
- Point-value widgets (text view, gauge, bar, LED) show the value at the end of the selected range.
|
||||
- The **Live** button resumes real-time streaming.
|
||||
- Status overlays on plot widgets indicate loading, empty, or error states.
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-functional Requirements
|
||||
|
||||
| Requirement | Target |
|
||||
|-------------|--------|
|
||||
@@ -210,4 +354,5 @@ When the server has archive access for all signals on the current canvas:
|
||||
| Concurrent clients | ≥ 20 simultaneous browser clients |
|
||||
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
|
||||
| Frontend responsiveness | 60 fps canvas rendering during live updates |
|
||||
| Screen DPI | Frontend adapts to device pixel ratio |
|
||||
| Screen DPI | UI scales with viewport height; manual zoom 50–250% |
|
||||
| Plot buffer | 200,000 samples per signal retained in-browser |
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Guide: Configuring uopi for ITER CS-Studio
|
||||
|
||||
This guide explains how to configure **uopi** to work within the **ITER CODAC (Control System Studio / Phoebus)** environment. Since uopi is a single portable binary, it can be deployed alongside or as a lightweight web-based alternative to standard CS-Studio screens.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisite Information
|
||||
|
||||
To connect uopi to the ITER control system, you need three pieces of information usually found in your standard CODAC environment or terminal:
|
||||
|
||||
| Info Needed | Purpose | Where to find it in ITER |
|
||||
| :--- | :--- | :--- |
|
||||
| **CA Addr List** | PV Discovery via UDP | Run `echo $EPICS_CA_ADDR_LIST` in a terminal. |
|
||||
| **Archiver URL** | Historical Trends | Check your CS-Studio preferences under *Archive Appliance* (usually `http://arch-mgmt:17665/`). |
|
||||
| **CFS URL** | Automatic PV Search | Check CS-Studio preferences under *Channel Finder* (usually `http://cf-service:8080/ChannelFinder`). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration (`uopi.toml`)
|
||||
|
||||
Create a file named `uopi.toml` in the directory where you will run uopi. Use the values found above:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen = ":8080" # The port uopi will serve the web HMI on
|
||||
storage_dir = "./interfaces" # Where your XML screens will be saved
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
# Paste the output of 'echo $EPICS_CA_ADDR_LIST' here
|
||||
ca_addr_list = "10.0.x.x 10.0.x.255"
|
||||
# ITER Archive Appliance URL
|
||||
archive_url = "http://arch-mgmt:17665/"
|
||||
# ITER Channel Finder URL
|
||||
channel_finder_url = "http://cf-service:8080/ChannelFinder"
|
||||
|
||||
# Automatic discovery on startup
|
||||
auto_sync_from_archiver = true
|
||||
auto_sync_filter = "tags=production"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment in the ITER Environment
|
||||
|
||||
### A. Running via Terminal
|
||||
If you are on a CODAC workstation or a server with network access to the Plant System:
|
||||
1. Copy the `uopi` binary to the machine.
|
||||
2. Run it pointing to your config:
|
||||
```bash
|
||||
./uopi -config uopi.toml
|
||||
```
|
||||
3. Open a browser and navigate to `http://<machine-ip>:8080`.
|
||||
|
||||
### B. Offline Usage (No Internet)
|
||||
uopi is built with **zero external dependencies**. All scripts and the favicon are embedded. No `npm` or `curl` calls are made at runtime. It will function perfectly on isolated ITER networks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Discovering ITER PVs
|
||||
|
||||
Once uopi is running:
|
||||
|
||||
1. **Automatic Discovery:** If `auto_sync_from_archiver` is set to `true`, the sidebar will be pre-populated with thousands of ITER PVs on boot.
|
||||
2. **Smart Grouping:** Click the **"Group By"** dropdown in the sidebar. Select **"Area"** or **"System"**. uopi will use the metadata from the ITER Channel Finder to organize PVs into folders (e.g., `Vacuum`, `Cryogenics`, `PowerSupply`).
|
||||
3. **Advanced Search:**
|
||||
* Click the **🔍 (Advanced Search)** icon in the sidebar.
|
||||
* Use the **Channel Finder** tab to find signals by tags (e.g., `interlock`) or properties (e.g., `iocName`).
|
||||
* Use the **Archive Appliance** tab to perform a glob search (e.g., `*TEMP*`) directly against the ITER Archiver database.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transitioning from CS-Studio (`.bob` / `.opi`)
|
||||
|
||||
uopi uses a custom XML format for its screens, but the layout logic is similar to Phoebus/CS-Studio.
|
||||
|
||||
* **Signals:** Just like in CS-Studio, you can drag PVs from the sidebar directly onto the canvas to create widgets.
|
||||
* **Historical Data:** Any PV discovered via the Archiver tab or configured with the Archiver URL will automatically show historical data when added to a **Plot Widget**.
|
||||
* **Synthetic Signals:** You can create complex ITER-specific calculations (e.g., `R = V/I`) by clicking the **Σ** icon and combining multiple EPICS PVs into a single virtual signal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
* **Timeout Error:** If `caget` times out in uopi, verify that `ca_addr_list` includes the correct broadcast address for your ITER subnet (e.g., ends in `.255`).
|
||||
* **Metadata Missing:** If PVs show up but have no units/descriptions, ensure the `channel_finder_url` is reachable from the uopi binary.
|
||||
+237
-88
@@ -20,31 +20,31 @@
|
||||
| Package | Purpose |
|
||||
| ---------------------------- | ------------------------------------------------------ |
|
||||
| `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) |
|
||||
| `go-epics/ca` or CGo wrapper | EPICS Channel Access |
|
||||
| CGo wrapper to `libca` | EPICS Channel Access |
|
||||
| `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals |
|
||||
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) |
|
||||
| `BurntSushi/toml` | TOML config parsing |
|
||||
| `encoding/xml` (stdlib) | Interface file serialisation |
|
||||
| `net/http` (stdlib) | HTTP server and static file serving |
|
||||
|
||||
### 1.2 Frontend — Svelte + TypeScript
|
||||
### 1.2 Frontend — Preact + TypeScript
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required.
|
||||
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival.
|
||||
- Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
|
||||
- esbuild (invoked via its Go API) bundles the TypeScript/TSX source in milliseconds with no Node.js or npm dependency at build time.
|
||||
- TypeScript catches signal subscription and widget property type errors at build time.
|
||||
- All vendor JS/CSS is checked into `web/vendor/` so the repo builds without internet access.
|
||||
|
||||
**Key dependencies:**
|
||||
**Key dependencies (vendored):**
|
||||
|
||||
| Package | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------ |
|
||||
| `svelte` + `vite` | Framework and build toolchain |
|
||||
| ---------------- | --------------------------------------------------------------- |
|
||||
| `preact` 10 | Virtual DOM UI framework |
|
||||
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
|
||||
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) |
|
||||
| `svelte-konva` | Svelte bindings for Konva |
|
||||
| `uplot.css` | uPlot default stylesheet |
|
||||
|
||||
**Intentionally excluded:** React, Vue, WebGPU, jQuery.
|
||||
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,30 +58,43 @@ uopi/
|
||||
│ ├── broker/ # signal fan-out to clients
|
||||
│ ├── datasource/
|
||||
│ │ ├── iface.go # DataSource interface
|
||||
│ │ ├── epics/ # EPICS CA/PVA implementation
|
||||
│ │ └── synthetic/ # synthetic signal engine
|
||||
│ ├── lua/ # Lua sandbox helpers
|
||||
│ ├── dsp/ # DSP functions (wraps gonum + custom)
|
||||
│ │ ├── epics/ # EPICS CA implementation (CGo)
|
||||
│ │ └── synthetic/ # synthetic signal engine + DSP bridge
|
||||
│ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
|
||||
│ ├── storage/ # interface XML read/write
|
||||
│ └── api/ # REST handler functions
|
||||
├── web/ # Svelte source
|
||||
│ ├── src/
|
||||
├── web/
|
||||
│ ├── embed.go # //go:embed dist — exports FS to Go
|
||||
│ ├── src/ # TypeScript/TSX source (Preact)
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── ws.ts # WebSocket client + subscription manager
|
||||
│ │ │ ├── stores.ts # Svelte stores for signal values
|
||||
│ │ │ ├── widgets/ # one .svelte file per widget type
|
||||
│ │ │ └── editor/ # edit-mode canvas, toolbar, properties pane
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── +page.svelte # view mode
|
||||
│ │ │ └── edit/+page.svelte # edit mode
|
||||
│ │ └── app.html
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│ │ │ ├── stores.ts # signal value + metadata stores
|
||||
│ │ │ ├── types.ts # shared TypeScript interfaces
|
||||
│ │ │ ├── xml.ts # interface XML parse/serialize
|
||||
│ │ │ └── format.ts # value formatting helpers
|
||||
│ │ ├── widgets/ # one .tsx file per widget type
|
||||
│ │ ├── App.tsx # top-level component, mode routing
|
||||
│ │ ├── ViewMode.tsx # view mode layout + tabs
|
||||
│ │ ├── EditMode.tsx # edit mode layout + toolbar
|
||||
│ │ ├── Canvas.tsx # live HMI canvas (view mode)
|
||||
│ │ ├── EditCanvas.tsx # free-form widget editor canvas
|
||||
│ │ ├── PlotPanel.tsx # live plot side-panel (Plot tab)
|
||||
│ │ ├── InfoPanel.tsx # signal info side-panel
|
||||
│ │ ├── ZoomControl.tsx # UI zoom A-/A+ control
|
||||
│ │ ├── SyntheticWizard.tsx # new synthetic signal dialog
|
||||
│ │ ├── SyntheticEditor.tsx # edit existing synthetic signal
|
||||
│ │ ├── LuaEditor.tsx # Lua code editor with syntax highlight
|
||||
│ │ └── styles.css # all component styles
|
||||
│ ├── vendor/ # vendored JS/CSS (preact, uplot, echarts)
|
||||
│ └── dist/ # built frontend — generated, not committed
|
||||
├── tools/buildfrontend/ # esbuild Go API bundler (go generate)
|
||||
├── docs/ # specs, work plan
|
||||
├── CLAUDE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Architecture
|
||||
@@ -174,21 +187,39 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
||||
Base path: `/api/v1`
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | -------------------------------------------- |
|
||||
| --------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
|
||||
| GET | `/interfaces` | List saved interfaces |
|
||||
| POST | `/interfaces` | Create a new interface (body: XML) |
|
||||
| GET | `/interfaces/:id` | Download interface XML |
|
||||
| PUT | `/interfaces/:id` | Update interface XML |
|
||||
| DELETE | `/interfaces/:id` | Delete interface |
|
||||
| POST | `/interfaces/:id/clone` | Clone an interface |
|
||||
| GET | `/signals/search?q=` | Search signals across all sources |
|
||||
| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
|
||||
| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
|
||||
| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
|
||||
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
|
||||
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
|
||||
| POST | `/interfaces/{id}/clone` | Clone an interface |
|
||||
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
|
||||
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
|
||||
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
|
||||
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
|
||||
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| GET, POST | `/folders` | List or create panel folders |
|
||||
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| GET | `/usergroups` | List configured users and groups (for sharing) |
|
||||
| GET, PUT | `/groups` | Read or set group definitions |
|
||||
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
||||
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
|
||||
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
|
||||
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
|
||||
Mutating requests are gated by the access middleware (§8): global level for writes,
|
||||
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
|
||||
endpoints and for any change to a panel's `<logic>` block.
|
||||
|
||||
### 3.5 EPICS Data Source
|
||||
|
||||
- Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available.
|
||||
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it.
|
||||
- Uses CGo bindings to EPICS Base `libca` (Channel Access).
|
||||
- Channel connections are lazy: connected on first Subscribe, disconnected when the broker releases it.
|
||||
- On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings).
|
||||
- `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel.
|
||||
- Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`).
|
||||
@@ -198,13 +229,36 @@ Base path: `/api/v1`
|
||||
|
||||
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
|
||||
- Processing nodes are re-evaluated whenever any upstream signal emits a new value.
|
||||
- Built-in node types implemented on top of `gonum/dsp` and custom code.
|
||||
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
|
||||
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
|
||||
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
|
||||
- The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
|
||||
|
||||
**Built-in node types:**
|
||||
|
||||
| Node type | Parameters | Description |
|
||||
| ------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `source` | `ds`, `name` | Reads a signal from any data source |
|
||||
| `gain` | `factor` | Multiplies by a constant |
|
||||
| `offset` | `value` | Adds a constant |
|
||||
| `moving_avg` | `window` (samples) | Rolling mean |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
|
||||
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
|
||||
|
||||
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x − y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
|
||||
|
||||
**Lua node:** Receives `inputs` table (indexed by signal name) and a persistent `state` table across calls. The `os`, `io`, `package`, and `debug` libraries are disabled.
|
||||
|
||||
### 3.7 Interface Storage
|
||||
|
||||
Interfaces are stored as XML files in a configurable directory on the server.
|
||||
Interfaces are stored as XML files in a configurable directory on the server. The
|
||||
`<interface>` element carries an optional `kind` attribute (`panel` default, or `plot`);
|
||||
plot panels add a nested `<layout>` split tree referencing plot widgets by id. Beyond
|
||||
widgets, an interface may also contain panel-local variables and a `<logic>` flow graph
|
||||
(`<node>` + `<param>` + `<wire>`), all round-tripping through the same XML.
|
||||
|
||||
Per-panel access rules and folder placement are kept in a sidecar `acl.json` in the same
|
||||
directory (not in the interface XML), and prior versions of each panel are retained for the
|
||||
version history endpoints.
|
||||
|
||||
```xml
|
||||
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
|
||||
@@ -215,6 +269,7 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
||||
<option key="yMin" value="auto"/>
|
||||
<option key="yMax" value="auto"/>
|
||||
<option key="timeWindow" value="60"/>
|
||||
<option key="legend" value="bottom"/>
|
||||
</widget>
|
||||
<widget id="w2" type="led" x="50" y="50" w="80" h="80">
|
||||
<signal ds="epics" name="EPICS:STATUS"/>
|
||||
@@ -226,51 +281,117 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
||||
</interface>
|
||||
```
|
||||
|
||||
### 3.8 Panel Logic Engine
|
||||
|
||||
Panel logic is a client-side flow engine (`web/src/lib/logic.ts`, singleton `logicEngine`).
|
||||
The graph is built/edited in the panel editor's Logic tab (`LogicEditor.tsx`) and serialized
|
||||
into the interface XML. In View mode, `Canvas` calls `logicEngine.load(iface.logic)` on
|
||||
mount and `clear()` on unmount. The engine subscribes to every referenced signal into a live
|
||||
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
|
||||
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
|
||||
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
|
||||
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
|
||||
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
|
||||
component.
|
||||
|
||||
### 3.9 Control Logic Engine
|
||||
|
||||
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
|
||||
the root context, independent of any client. The engine subscribes to the broker, fires on
|
||||
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
|
||||
and writes results back to signals. Graphs are persisted by a store and managed via
|
||||
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||
can be individually enabled/disabled.
|
||||
|
||||
### 3.10 Access Control
|
||||
|
||||
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
|
||||
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
|
||||
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
|
||||
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
|
||||
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
|
||||
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
|
||||
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
|
||||
(`canEditLogic`) so the UI can hide logic-editing affordances.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Architecture
|
||||
|
||||
### 4.1 WebSocket Client (`ws.ts`)
|
||||
### 4.1 WebSocket Client (`lib/ws.ts`)
|
||||
|
||||
- Singleton WebSocket connection, reconnects with exponential back-off.
|
||||
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message.
|
||||
- Incoming updates are dispatched to signal stores.
|
||||
- Incoming updates are dispatched to per-signal stores.
|
||||
- `wsClient.history(sig, start, end, maxPoints)` returns a Promise resolving to timestamped point arrays.
|
||||
|
||||
### 4.2 Signal Stores (`stores.ts`)
|
||||
### 4.2 Signal Stores (`lib/stores.ts`)
|
||||
|
||||
```typescript
|
||||
// One writable store per subscribed signal
|
||||
const signalStores = new Map<string, Writable<SignalValue>>();
|
||||
// One nanostores atom per subscribed signal
|
||||
const signalStores = new Map<string, SignalStore>();
|
||||
|
||||
function getStore(signal: string): Readable<SignalValue> { ... }
|
||||
export function getSignalStore(ref: SignalRef): SignalStore { ... }
|
||||
export function getMetaStore(ref: SignalRef): MetaStore { ... }
|
||||
```
|
||||
|
||||
Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update.
|
||||
Widgets subscribe to stores directly; store updates trigger re-renders only in the consuming component.
|
||||
|
||||
### 4.3 Edit Mode Canvas
|
||||
### 4.3 Edit Mode Canvas (`EditCanvas.tsx`)
|
||||
|
||||
The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`:
|
||||
The edit canvas is a free-form HTML div with absolutely positioned widget components:
|
||||
|
||||
- Each widget is a Konva Group containing its visual elements.
|
||||
- A `Transformer` node provides resize handles and enforces minimum sizes.
|
||||
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`.
|
||||
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100).
|
||||
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry.
|
||||
- Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
|
||||
- Selection shows a CSS-outlined bounding box with 8 resize handles rendered as small squares.
|
||||
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from the drop event offset.
|
||||
- Undo/redo uses an array of past interface snapshots (max depth 50).
|
||||
- Align/distribute operations compute target positions geometrically and generate a single undo entry.
|
||||
- Multi-select via Ctrl+click or rubber-band area select.
|
||||
|
||||
### 4.4 Widget Rendering in View Mode
|
||||
### 4.4 Widget Rendering in View Mode (`Canvas.tsx`)
|
||||
|
||||
In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that:
|
||||
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
|
||||
|
||||
1. Subscribes to its signal store(s) in `onMount`.
|
||||
2. Receives reactive updates and re-renders only its own DOM subtree.
|
||||
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
|
||||
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
|
||||
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
|
||||
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
|
||||
|
||||
Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component.
|
||||
### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`)
|
||||
|
||||
### 4.5 DPI Adaptation
|
||||
Plot panels are interfaces of `kind === 'plot'` whose plots fill the viewport in a recursive
|
||||
split layout (`lib/plotLayout.ts` holds the pure tree helpers; `SplitLayout.tsx` is the
|
||||
shared presentational renderer with draggable dividers):
|
||||
|
||||
- CSS uses `rem` units throughout for text.
|
||||
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed.
|
||||
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`.
|
||||
- In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
|
||||
- In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
|
||||
to select, and signal-drop to add a signal to that pane's plot.
|
||||
- Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply.
|
||||
- Layout edits (split/close/resize) participate in the editor's undo/redo.
|
||||
|
||||
### 4.6 Resizable Panels
|
||||
|
||||
Both edit and view modes support mouse-drag panel resizing:
|
||||
|
||||
- **View mode**: drag handle between the interface list pane and the main content area.
|
||||
- **Edit mode**: drag handles on both sides of the central canvas (signal tree ↔ canvas, canvas ↔ properties pane).
|
||||
- Handle width: 5 px, cursor changes to `ew-resize` on hover.
|
||||
- Minimum panel widths enforced to prevent collapse below usable size.
|
||||
|
||||
### 4.7 HiDPI / Zoom Support
|
||||
|
||||
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
|
||||
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
|
||||
- **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
|
||||
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
|
||||
|
||||
### 4.8 Lua Editor (`LuaEditor.tsx`)
|
||||
|
||||
A syntax-highlighted code editor for Lua scripts in the Synthetic signal wizard:
|
||||
|
||||
- Implemented as a `<textarea>` overlaid on a `<pre>` element; the textarea has `color: transparent; caret-color: #e2e8f0` so only the caret is visible — the `<pre>` provides the coloured text behind it.
|
||||
- Tokeniser handles: `--` line comments, `"..."` / `'...'` string literals, `[[...]]` long strings, hex and float numeric literals, and all Lua 5.1 keywords.
|
||||
- Scroll position is synchronised between textarea and pre on every scroll event.
|
||||
|
||||
---
|
||||
|
||||
@@ -278,34 +399,42 @@ Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own ca
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
```makefile
|
||||
# Build static binary (requires EPICS base installed or cross-compiled libca)
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
```bash
|
||||
# Full build (frontend then backend)
|
||||
make all
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
# Backend only (frontend must already be built)
|
||||
make backend
|
||||
|
||||
# Run a single test
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# Single Go test
|
||||
go test ./internal/broker/... -run TestFanOut
|
||||
|
||||
# Go vet
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # dev server at http://localhost:5173 (proxies /api to backend)
|
||||
npm run build # outputs to web/dist/
|
||||
npm run check # svelte-check type checking
|
||||
npm run lint # eslint + prettier
|
||||
make frontend
|
||||
# or equivalently:
|
||||
go generate ./web/...
|
||||
```
|
||||
|
||||
### 5.3 Combined Build
|
||||
No Node.js, npm, or any JS build tool is required on the host. The bundler:
|
||||
- Reads entry point `web/src/main.tsx`.
|
||||
- Resolves `preact`, `uplot`, and `echarts` imports from `web/vendor/`.
|
||||
- Outputs `web/dist/main.js` and `web/dist/main.css`.
|
||||
- Copies `web/dist/index.html`, vendor CSS, and other static assets.
|
||||
|
||||
A `Makefile` at the repo root:
|
||||
### 5.3 Combined Build
|
||||
|
||||
```makefile
|
||||
.PHONY: all frontend backend clean
|
||||
@@ -313,20 +442,19 @@ A `Makefile` at the repo root:
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd web && npm ci && npm run build
|
||||
go run ./tools/buildfrontend
|
||||
|
||||
backend: frontend
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
backend:
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd web && npm run check
|
||||
|
||||
clean:
|
||||
rm -rf dist/ web/dist/
|
||||
```
|
||||
|
||||
The backend's `//go:embed web/dist` directive picks up the built frontend automatically.
|
||||
The backend's `//go:embed dist` directive in `web/embed.go` picks up the built frontend automatically. `main.go` does `fs.Sub(web.FS, "dist")` to serve a clean root.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,17 +467,31 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces"
|
||||
|
||||
# Access control (all optional)
|
||||
trusted_user_header = "" # header carrying the proxy-authenticated user
|
||||
default_user = "" # identity when the header is absent (LAN/dev)
|
||||
logic_editors = [] # users/groups allowed to edit panel & control logic
|
||||
|
||||
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
|
||||
# user = "guest"
|
||||
# level = "readonly"
|
||||
|
||||
# [[groups]] # named user sets for per-panel sharing
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
|
||||
archive_url = "" # EPICS Archive Appliance URL
|
||||
channel_finder_url = "" # EPICS Channel Finder URL
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
```
|
||||
|
||||
All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc.
|
||||
All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
||||
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
|
||||
|
||||
---
|
||||
|
||||
@@ -359,11 +501,11 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
|
||||
| ------------------ | --------------------------------------------------------------------------------- |
|
||||
| Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
|
||||
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs |
|
||||
| Low-pass filter | Unit tests: step response, frequency attenuation vs. analytical expectation |
|
||||
| Lua sandbox | Unit tests for sandbox isolation and API surface |
|
||||
| REST API | `httptest` integration tests |
|
||||
| WebSocket protocol | Integration tests with a test client |
|
||||
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
|
||||
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
|
||||
|
||||
---
|
||||
|
||||
@@ -372,13 +514,20 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
|
||||
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets.
|
||||
- WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
|
||||
- Interface XML parsing: use strict schema validation to prevent XXE.
|
||||
- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment.
|
||||
- **Identity & access control:** the end-user identity is taken from a header set by a
|
||||
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
|
||||
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
|
||||
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
|
||||
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
|
||||
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
|
||||
unproxied/SSH-tunnel deployment model.
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-goals (v1)
|
||||
|
||||
- User authentication and authorisation.
|
||||
- Built-in user authentication (a login page / credential store) — identity is delegated to
|
||||
the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented.
|
||||
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
|
||||
- Windows or macOS server binary.
|
||||
- Mobile-optimised frontend layout.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package access implements uopi's global user-access policy: every user is
|
||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
||||
// specific users to read-only or no access. It also resolves the per-request
|
||||
// user identity and the user→group memberships defined in config.
|
||||
//
|
||||
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
|
||||
// layered on top of this in a later phase.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is a global access level. Higher levels include lower ones.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelNone denies all access.
|
||||
LevelNone Level = iota
|
||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
||||
LevelRead
|
||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
||||
LevelWrite
|
||||
)
|
||||
|
||||
// String renders the level using the same tokens accepted by ParseLevel and
|
||||
// surfaced to the frontend via /api/v1/me.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelNone:
|
||||
return "none"
|
||||
case LevelRead:
|
||||
return "readonly"
|
||||
default:
|
||||
return "write"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
||||
// read-only, since the only reason to list a user is to limit them.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "noaccess", "none", "no":
|
||||
return LevelNone
|
||||
case "readonly", "read", "ro":
|
||||
return LevelRead
|
||||
case "write", "readwrite", "rw", "full":
|
||||
return LevelWrite
|
||||
default:
|
||||
return LevelRead
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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. 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),
|
||||
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)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
p.blacklist[u] = ParseLevel(lvl)
|
||||
}
|
||||
for g, members := range groups {
|
||||
g = strings.TrimSpace(g)
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
p.groupNames = append(p.groupNames, g)
|
||||
for _, m := range members {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.groupNames)
|
||||
return p
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
out := make([]string, len(p.groupNames))
|
||||
copy(out, p.groupNames)
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||
func (p *Policy) ResolveUser(headerValue string) string {
|
||||
u := strings.TrimSpace(headerValue)
|
||||
if u == "" {
|
||||
return p.defaultUser
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// Level returns the global access level for a user. Users that are neither
|
||||
// blacklisted nor anonymous get full write access.
|
||||
func (p *Policy) Level(user string) Level {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
||||
return LevelWrite
|
||||
}
|
||||
if lvl, ok := p.blacklist[user]; ok {
|
||||
return lvl
|
||||
}
|
||||
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)]
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ────────────────────────────────────────────
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithUser returns a copy of ctx carrying the resolved end-user identity.
|
||||
func WithUser(ctx context.Context, user string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, user)
|
||||
}
|
||||
|
||||
// UserFrom returns the identity stored by WithUser, or "" if none.
|
||||
func UserFrom(ctx context.Context) string {
|
||||
u, _ := ctx.Value(ctxKey{}).(string)
|
||||
return u
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+874
-7
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -38,9 +41,19 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
if err != nil {
|
||||
t.Fatal("storage.New:", err)
|
||||
}
|
||||
acl, err := panelacl.New(dir)
|
||||
if err != nil {
|
||||
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, "", 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() {
|
||||
@@ -396,7 +409,7 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a real interface first to get a valid ID.
|
||||
id, err := store.Create([]byte(sampleXML))
|
||||
id, err := store.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatal("create:", err)
|
||||
}
|
||||
@@ -416,7 +429,7 @@ func TestStorageValidateID(t *testing.T) {
|
||||
if _, err := store.Get(bad); err == nil {
|
||||
t.Errorf("Get(%q) should have failed, got nil error", bad)
|
||||
}
|
||||
if err := store.Update(bad, []byte(sampleXML)); err == nil {
|
||||
if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
|
||||
t.Errorf("Update(%q) should have failed", bad)
|
||||
}
|
||||
if err := store.Delete(bad); err == nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
@@ -41,18 +42,38 @@ type Broker struct {
|
||||
sources map[string]datasource.DataSource
|
||||
subs map[SignalRef]*signalSub
|
||||
|
||||
maxInterval time.Duration // 0 = unlimited
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Option is a functional option for Broker configuration.
|
||||
type Option func(*Broker)
|
||||
|
||||
// WithMaxUpdateRate limits fan-out to at most hz updates per second per signal.
|
||||
// When upstream delivers faster, intermediate values are coalesced: the most
|
||||
// recent value in each interval is forwarded once the interval elapses.
|
||||
// A value ≤ 0 disables rate limiting (the default).
|
||||
func WithMaxUpdateRate(hz float64) Option {
|
||||
return func(b *Broker) {
|
||||
if hz > 0 {
|
||||
b.maxInterval = time.Duration(float64(time.Second) / hz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a Broker whose upstream subscriptions are bound to ctx.
|
||||
// Cancel ctx (or the parent context passed to main) to shut everything down.
|
||||
func New(ctx context.Context, log *slog.Logger) *Broker {
|
||||
return &Broker{
|
||||
func New(ctx context.Context, log *slog.Logger, opts ...Option) *Broker {
|
||||
b := &Broker{
|
||||
ctx: ctx,
|
||||
sources: make(map[string]datasource.DataSource),
|
||||
subs: make(map[SignalRef]*signalSub),
|
||||
log: log,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Register adds a DataSource to the broker. Must be called before Subscribe.
|
||||
@@ -169,7 +190,36 @@ func (b *Broker) unsubscribe(ref SignalRef, ch chan<- Update) {
|
||||
|
||||
// fanOut reads values from rawCh and dispatches them to all registered clients.
|
||||
// It exits when sub.done is closed or rawCh is closed.
|
||||
//
|
||||
// When b.maxInterval > 0, updates that arrive faster than the interval are
|
||||
// coalesced: only the most recent value in each interval window is forwarded,
|
||||
// delivered at the end of the interval by a flush ticker. This ensures the
|
||||
// latest value is always eventually seen even when the source fires rapidly.
|
||||
func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) {
|
||||
maxInterval := b.maxInterval
|
||||
|
||||
var lastSent time.Time
|
||||
var pending *Update
|
||||
|
||||
// Only allocate a ticker when rate-limiting is configured.
|
||||
var flushC <-chan time.Time
|
||||
if maxInterval > 0 {
|
||||
t := time.NewTicker(maxInterval)
|
||||
defer t.Stop()
|
||||
flushC = t.C
|
||||
}
|
||||
|
||||
dispatch := func(u Update) {
|
||||
sub.mu.RLock()
|
||||
for ch := range sub.clients {
|
||||
select {
|
||||
case ch <- u:
|
||||
default: // slow consumer: drop rather than block
|
||||
}
|
||||
}
|
||||
sub.mu.RUnlock()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case v, ok := <-rawCh:
|
||||
@@ -177,15 +227,23 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
|
||||
return
|
||||
}
|
||||
update := Update{Ref: ref, Value: v}
|
||||
sub.mu.RLock()
|
||||
for ch := range sub.clients {
|
||||
select {
|
||||
case ch <- update:
|
||||
default:
|
||||
// slow consumer: drop rather than block
|
||||
// Rate-limit: if an interval is configured and the last delivery
|
||||
// was recent, hold this update as pending (coalesce).
|
||||
if maxInterval > 0 && !lastSent.IsZero() && time.Since(lastSent) < maxInterval {
|
||||
pending = &update
|
||||
continue
|
||||
}
|
||||
lastSent = time.Now()
|
||||
pending = nil
|
||||
dispatch(update)
|
||||
|
||||
case <-flushC:
|
||||
// Deliver the most recent coalesced value, if any.
|
||||
if pending != nil {
|
||||
lastSent = time.Now()
|
||||
dispatch(*pending)
|
||||
pending = nil
|
||||
}
|
||||
sub.mu.RUnlock()
|
||||
|
||||
case <-sub.done:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
// newBrokerN builds a broker backed by a stub with n dynamically generated signals.
|
||||
func newBrokerN(tb testing.TB, n int) (*broker.Broker, context.CancelFunc) {
|
||||
tb.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.Default()
|
||||
b := broker.New(ctx, log)
|
||||
ds := stub.NewN(n)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
cancel()
|
||||
tb.Fatal(err)
|
||||
}
|
||||
b.Register(ds)
|
||||
return b, cancel
|
||||
}
|
||||
|
||||
// TestStress_ManySignalsManyClients subscribes 20 clients to 500 signals each,
|
||||
// runs for 2 seconds, then verifies delivery counts and zero subscription leaks.
|
||||
func TestStress_ManySignalsManyClients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 500
|
||||
nClients = 20
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
goroutinesBefore := runtime.NumGoroutine()
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
defer cancel()
|
||||
|
||||
// Each client has its own channel and subscribes to all nSignals signals.
|
||||
type clientState struct {
|
||||
ch chan broker.Update
|
||||
unsub []func()
|
||||
}
|
||||
clients := make([]clientState, nClients)
|
||||
|
||||
for i := range nClients {
|
||||
ch := make(chan broker.Update, 2048)
|
||||
unsubs := make([]func(), nSignals)
|
||||
for j := range nSignals {
|
||||
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", j)}
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("client %d subscribe pv_%d: %v", i, j, err)
|
||||
}
|
||||
unsubs[j] = unsub
|
||||
}
|
||||
clients[i] = clientState{ch: ch, unsub: unsubs}
|
||||
}
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != nSignals {
|
||||
t.Errorf("expected %d active upstream subscriptions, got %d", nSignals, n)
|
||||
}
|
||||
|
||||
// Drain updates concurrently for the test duration.
|
||||
var totalReceived atomic.Int64
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range nClients {
|
||||
wg.Add(1)
|
||||
ch := clients[i].ch
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var n int64
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
n++
|
||||
case <-stop:
|
||||
totalReceived.Add(n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
total := totalReceived.Load()
|
||||
// At 10 Hz for duration seconds: nSignals * nClients * duration.Seconds() * 10
|
||||
minExpected := int64(nSignals) * int64(nClients) * int64(duration.Seconds()) * 5 // conservative: 50%
|
||||
t.Logf("received %d updates (%d clients × %d signals × %.0fs @ 10Hz, min=%d)",
|
||||
total, nClients, nSignals, duration.Seconds(), minExpected)
|
||||
if total < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
|
||||
}
|
||||
|
||||
// Unsubscribe all clients.
|
||||
for _, c := range clients {
|
||||
for _, unsub := range c.unsub {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
// Give the broker time to tear down upstream subscriptions.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d active subscriptions remain after all clients unsubscribed", n)
|
||||
}
|
||||
|
||||
cancel()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
goroutinesAfter := runtime.NumGoroutine()
|
||||
leaked := goroutinesAfter - goroutinesBefore
|
||||
t.Logf("goroutines: before=%d after=%d delta=%d", goroutinesBefore, goroutinesAfter, leaked)
|
||||
if leaked > 20 {
|
||||
t.Errorf("goroutine leak: started with %d, ended with %d (%d extra)", goroutinesBefore, goroutinesAfter, leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_RapidSubscribeUnsubscribe hammers subscribe/unsubscribe on a small
|
||||
// signal set from many goroutines concurrently to expose races and deadlocks.
|
||||
func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 20
|
||||
nGoroutines = 50
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
var ops atomic.Int64
|
||||
|
||||
for range nGoroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch := make(chan broker.Update, 64)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Pick a signal and rapidly subscribe/unsubscribe.
|
||||
idx := int(ops.Load()) % nSignals
|
||||
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", idx)}
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Errorf("subscribe pv_%d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
ops.Add(1)
|
||||
// Drain a bit before unsubscribing.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
unsub()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("completed %d subscribe/unsubscribe cycles in %s", ops.Load(), duration)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak after rapid churn: %d remain", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_SharedSignalManyClients verifies that a single high-rate signal
|
||||
// fans out correctly to many concurrent clients with no drops for fast consumers.
|
||||
func TestStress_SharedSignalManyClients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nClients = 50
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBroker(t) // uses stub.New() which has counter_fast at 1 ms
|
||||
defer cancel()
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
channels := make([]chan broker.Update, nClients)
|
||||
unsubs := make([]func(), nClients)
|
||||
|
||||
for i := range nClients {
|
||||
ch := make(chan broker.Update, 4096)
|
||||
channels[i] = ch
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe client %d: %v", i, err)
|
||||
}
|
||||
unsubs[i] = unsub
|
||||
}
|
||||
defer func() {
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
}()
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != 1 {
|
||||
t.Errorf("expected exactly 1 upstream subscription for shared signal, got %d", n)
|
||||
}
|
||||
|
||||
var totalReceived atomic.Int64
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range nClients {
|
||||
wg.Add(1)
|
||||
ch := channels[i]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var n int64
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
n++
|
||||
case <-stop:
|
||||
totalReceived.Add(n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
total := totalReceived.Load()
|
||||
// counter_fast ticks at 1ms → ~1000 Hz → 2000 updates × 50 clients = 100000 expected minimum
|
||||
minExpected := int64(nClients) * int64(duration.Seconds()) * 500 // 50% delivery ok due to buffering
|
||||
t.Logf("shared signal: received %d updates across %d clients (min=%d)", total, nClients, minExpected)
|
||||
if total < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,68 @@ func TestUnknownSignal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxUpdateRate(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
const maxHz = 5.0 // 5 Hz → 200 ms interval
|
||||
|
||||
ds := stub.New() // counter_fast fires at 1 kHz
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := broker.New(ctx, slog.Default(), broker.WithMaxUpdateRate(maxHz))
|
||||
b.Register(ds)
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
ch := make(chan broker.Update, 512)
|
||||
unsub, err := b.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Collect updates for 2 seconds.
|
||||
var count int
|
||||
deadline := time.After(2 * time.Second)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
count++
|
||||
case <-deadline:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
// At 5 Hz for 2 s we expect ~10 updates (±3 for timer jitter + coalescing).
|
||||
t.Logf("MaxUpdateRate: received %d updates at %.0f Hz limit over 2s (expect ~10)", count, maxHz)
|
||||
if count < 5 || count > 20 {
|
||||
t.Errorf("expected ~10 updates at 5 Hz, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxUpdateRate_UnlimitedByDefault(t *testing.T) {
|
||||
b, cancel := newBroker(t) // no WithMaxUpdateRate → unlimited
|
||||
defer cancel()
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
ch := make(chan broker.Update, 4096)
|
||||
unsub, err := b.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// counter_fast fires at 1 kHz; over 500 ms we should see ≥ 400 updates.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
got := len(ch)
|
||||
t.Logf("Unlimited: received %d updates in 500ms from 1kHz signal", got)
|
||||
if got < 400 {
|
||||
t.Errorf("expected >= 400 updates with no rate limit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleSignals(t *testing.T) {
|
||||
b, cancel := newBroker(t)
|
||||
defer cancel()
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
@@ -11,16 +12,57 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Datasource DatasourceConfig `toml:"datasource"`
|
||||
// Groups are named sets of users, referenced by panel sharing rules.
|
||||
Groups []GroupDef `toml:"groups"`
|
||||
}
|
||||
|
||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
||||
type GroupDef struct {
|
||||
Name string `toml:"name"`
|
||||
Members []string `toml:"members"`
|
||||
}
|
||||
|
||||
// BlacklistEntry downgrades a specific user's global access level. Levels:
|
||||
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
|
||||
// with full access.
|
||||
type BlacklistEntry struct {
|
||||
User string `toml:"user"`
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Listen string `toml:"listen"`
|
||||
StorageDir string `toml:"storage_dir"`
|
||||
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
|
||||
|
||||
// TrustedUserHeader is the HTTP header from which the end-user identity is
|
||||
// read on each WebSocket connection (set by a trusted reverse proxy doing
|
||||
// authentication). The identity is used for EPICS writes so each session
|
||||
// can act as a different user. Empty disables it (writes use the server
|
||||
// identity). MUST only be enabled when a proxy strips any client-supplied
|
||||
// value, otherwise the header can be spoofed.
|
||||
TrustedUserHeader string `toml:"trusted_user_header"`
|
||||
|
||||
// DefaultUser is the identity used when the trusted user header is absent or
|
||||
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
|
||||
DefaultUser string `toml:"default_user"`
|
||||
|
||||
// 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 {
|
||||
Stub StubConfig `toml:"stub"`
|
||||
EPICS EPICSConfig `toml:"epics"`
|
||||
PVA PVAConfig `toml:"pva"`
|
||||
Synthetic SyntheticConfig `toml:"synthetic"`
|
||||
}
|
||||
|
||||
@@ -33,6 +75,14 @@ type EPICSConfig struct {
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
AutoSyncFilter string `toml:"auto_sync_filter"`
|
||||
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
||||
PVNames []string `toml:"pv_names"`
|
||||
}
|
||||
|
||||
type PVAConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AddrList []string `toml:"addr_list"`
|
||||
}
|
||||
|
||||
type SyntheticConfig struct {
|
||||
@@ -48,6 +98,7 @@ func Default() Config {
|
||||
Datasource: DatasourceConfig{
|
||||
Stub: StubConfig{Enabled: true},
|
||||
EPICS: EPICSConfig{Enabled: true},
|
||||
PVA: PVAConfig{Enabled: true},
|
||||
Synthetic: SyntheticConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
@@ -76,6 +127,20 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
|
||||
cfg.Server.StorageDir = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_MAX_UPDATE_RATE_HZ"); v != "" {
|
||||
if hz, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
cfg.Server.MaxUpdateRateHz = hz
|
||||
}
|
||||
}
|
||||
if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" {
|
||||
cfg.Server.TrustedUserHeader = v
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -85,6 +150,15 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||
cfg.Datasource.EPICS.ChannelFinderURL = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_AUTO_SYNC_FILTER"); v != "" {
|
||||
cfg.Datasource.EPICS.AutoSyncFilter = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER"); v != "" {
|
||||
cfg.Datasource.EPICS.AutoSyncFromArchiver = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string) string {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/config"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
if cfg.Server.Listen == "" {
|
||||
t.Error("Default Listen must not be empty")
|
||||
}
|
||||
if cfg.Server.StorageDir == "" {
|
||||
t.Error("Default StorageDir must not be empty")
|
||||
}
|
||||
if !cfg.Datasource.Stub.Enabled {
|
||||
t.Error("Stub should be enabled by default")
|
||||
}
|
||||
if !cfg.Datasource.EPICS.Enabled {
|
||||
t.Error("EPICS should be enabled by default")
|
||||
}
|
||||
if !cfg.Datasource.Synthetic.Enabled {
|
||||
t.Error("Synthetic should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyPath(t *testing.T) {
|
||||
// Empty path should return defaults without error.
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load with empty path: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != config.Default().Server.Listen {
|
||||
t.Errorf("Listen = %q, want default %q", cfg.Server.Listen, config.Default().Server.Listen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTOML(t *testing.T) {
|
||||
toml := `
|
||||
[server]
|
||||
listen = ":9090"
|
||||
storage_dir = "/tmp/uopi-test"
|
||||
|
||||
[datasource.stub]
|
||||
enabled = false
|
||||
|
||||
[datasource.epics]
|
||||
enabled = false
|
||||
ca_addr_list = "192.168.1.1"
|
||||
`
|
||||
f := filepath.Join(t.TempDir(), "uopi.toml")
|
||||
if err := os.WriteFile(f, []byte(toml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(f)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":9090" {
|
||||
t.Errorf("Listen = %q, want :9090", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.StorageDir != "/tmp/uopi-test" {
|
||||
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
|
||||
}
|
||||
if cfg.Datasource.Stub.Enabled {
|
||||
t.Error("Stub.Enabled should be false")
|
||||
}
|
||||
if cfg.Datasource.EPICS.Enabled {
|
||||
t.Error("EPICS.Enabled should be false")
|
||||
}
|
||||
if cfg.Datasource.EPICS.CAAddrList != "192.168.1.1" {
|
||||
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBadPath(t *testing.T) {
|
||||
_, err := config.Load("/no/such/file.toml")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_LISTEN", ":7777")
|
||||
t.Setenv("UOPI_SERVER_STORAGE_DIR", "/env/storage")
|
||||
t.Setenv("UOPI_EPICS_CA_ADDR_LIST", "10.0.0.1 10.0.0.2")
|
||||
t.Setenv("UOPI_EPICS_ARCHIVE_URL", "http://archive/")
|
||||
t.Setenv("UOPI_EPICS_CHANNEL_FINDER_URL", "http://cf/")
|
||||
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":7777" {
|
||||
t.Errorf("Listen = %q, want :7777", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.StorageDir != "/env/storage" {
|
||||
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
|
||||
}
|
||||
if cfg.Datasource.EPICS.CAAddrList != "10.0.0.1 10.0.0.2" {
|
||||
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
|
||||
}
|
||||
if cfg.Datasource.EPICS.ArchiveURL != "http://archive/" {
|
||||
t.Errorf("ArchiveURL = %q", cfg.Datasource.EPICS.ArchiveURL)
|
||||
}
|
||||
if cfg.Datasource.EPICS.ChannelFinderURL != "http://cf/" {
|
||||
t.Errorf("ChannelFinderURL = %q", cfg.Datasource.EPICS.ChannelFinderURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvTrimsWhitespace(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":8888" {
|
||||
t.Errorf("Listen = %q, expected whitespace trimmed", cfg.Server.Listen)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build epics
|
||||
|
||||
package epics
|
||||
|
||||
import (
|
||||
@@ -9,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
@@ -119,6 +118,55 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchArchivePVs queries the Archive Appliance management API for PV names
|
||||
// matching the given pattern (glob style, e.g. "PV:*").
|
||||
func searchArchivePVs(ctx context.Context, archiveURL, pattern string) ([]string, error) {
|
||||
if archiveURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build the request URL.
|
||||
// Format: GET {archiveURL}/mgmt/bpl/getAllPVs?pv={pattern}
|
||||
reqURL, err := url.Parse(archiveURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err)
|
||||
}
|
||||
reqURL = reqURL.JoinPath("mgmt", "bpl", "getAllPVs")
|
||||
|
||||
q := reqURL.Query()
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
// AA expects glob patterns. If no glob characters, wrap in stars.
|
||||
if !strings.ContainsAny(pattern, "*?[]") {
|
||||
pattern = "*" + pattern + "*"
|
||||
}
|
||||
q.Set("pv", pattern)
|
||||
reqURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: building request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("epics archive: unexpected status %d for search %q", resp.StatusCode, pattern)
|
||||
}
|
||||
|
||||
var pvs []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
|
||||
return nil, fmt.Errorf("epics archive: decoding response: %w", err)
|
||||
}
|
||||
|
||||
return pvs, nil
|
||||
}
|
||||
|
||||
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
|
||||
// as float64, string, bool, []interface{}, or nil) into one of the types
|
||||
// accepted by datasource.Value.Data.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package epics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cfChannel is the JSON structure returned by the Channel Finder Service.
|
||||
type cfChannel struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Properties []cfProperty `json:"properties"`
|
||||
Tags []cfTag `json:"tags"`
|
||||
}
|
||||
|
||||
type cfProperty struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
type cfTag struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// queryChannelFinder fetches channels from CFS matching the given query string.
|
||||
func queryChannelFinder(cfURL, query string) ([]cfChannel, error) {
|
||||
if cfURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
url := strings.TrimRight(cfURL, "/") + "/resources/channels"
|
||||
if query != "" {
|
||||
if strings.Contains(query, "=") || strings.Contains(query, "~") {
|
||||
// Query is already a structured filter (e.g. area=L1)
|
||||
url += "?" + query
|
||||
} else {
|
||||
// Query is just a PV name pattern
|
||||
url += "?~name=*" + query + "*"
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
slog.Info("CFS query", "url", url)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CFS request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("CFS returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var channels []cfChannel
|
||||
if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil {
|
||||
return nil, fmt.Errorf("CFS response parse error: %w", err)
|
||||
}
|
||||
slog.Info("CFS results", "count", len(channels))
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
func TestDiscovery_EnvironmentVariables(t *testing.T) {
|
||||
// 1. Setup a fake CA server.
|
||||
srv, err := testca.New([]testca.PVSpec{
|
||||
{Name: "DISCO:PV", DBFType: proto.DBFDouble, Count: 1, Value: 123.45},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("testca.New: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
// 2. Set the environment variable to point to this server.
|
||||
os.Setenv("EPICS_CA_ADDR_LIST", srv.UDPAddr())
|
||||
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
|
||||
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
|
||||
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
|
||||
|
||||
// 3. Create datasource with NO explicit address (should pick up from env).
|
||||
ds := epics.New("", "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
// 4. Verify discovery works.
|
||||
meta, err := ds.GetMetadata(ctx, "DISCO:PV")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata failed (discovery failed): %v", err)
|
||||
}
|
||||
if meta.Name != "DISCO:PV" {
|
||||
t.Errorf("got %q, want DISCO:PV", meta.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ConfigOverride(t *testing.T) {
|
||||
// 1. Setup TWO fake CA servers.
|
||||
srv1, _ := testca.New([]testca.PVSpec{{Name: "PV:1", DBFType: proto.DBFDouble, Value: 1.0}})
|
||||
defer srv1.Close()
|
||||
srv2, _ := testca.New([]testca.PVSpec{{Name: "PV:2", DBFType: proto.DBFDouble, Value: 2.0}})
|
||||
defer srv2.Close()
|
||||
|
||||
// 2. Point env to srv1.
|
||||
os.Setenv("EPICS_CA_ADDR_LIST", srv1.UDPAddr())
|
||||
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
|
||||
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
|
||||
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
|
||||
|
||||
// 3. Create datasource pointing explicitly to srv2 (should OVERRIDE env).
|
||||
ds := epics.New(srv2.UDPAddr(), "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
// 4. PV from srv2 should be found.
|
||||
if _, err := ds.GetMetadata(ctx, "PV:2"); err != nil {
|
||||
t.Errorf("PV:2 from config address not found: %v", err)
|
||||
}
|
||||
|
||||
// 5. PV from srv1 should NOT be found (since AutoAddrList is disabled by config override).
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel2()
|
||||
if _, err := ds.GetMetadata(ctx2, "PV:1"); err == nil {
|
||||
t.Error("PV:1 from env address was found, but config should have overridden it")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import "C"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -68,8 +69,12 @@ type caChannel struct {
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
|
||||
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
|
||||
// caCtx is the CA context created in Connect().
|
||||
Stored as unsafe.Pointer
|
||||
// because the C type (ca_client_context *) is opaque. Every goroutine
|
||||
// that calls CA functions must call caAttachContext(caCtx) first, because
|
||||
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
||||
@@ -85,10 +90,15 @@ type EPICS struct {
|
||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
||||
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||
// Appliance instance for history queries (may be empty).
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource {
|
||||
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
||||
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
|
||||
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
cfURL: cfURL,
|
||||
autoSyncFilter: autoSyncFilter,
|
||||
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||
channels: make(map[string]*caChannel),
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
@@ -120,9 +130,62 @@ func (e *EPICS) Connect(_ context.Context) error {
|
||||
}
|
||||
// Save the context so goroutines on other OS threads can attach to it.
|
||||
e.caCtx = C.caCurrentContext()
|
||||
|
||||
// Perform background sync from Channel Finder if configured.
|
||||
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||
go e.syncFromChannelFinder(context.Background())
|
||||
}
|
||||
|
||||
// Perform background sync from Archive Appliance if configured.
|
||||
if e.archiveURL != "" && e.autoSyncFromArchiver {
|
||||
go e.syncFromArchiver(context.Background())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
|
||||
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
|
||||
if err != nil {
|
||||
slog.Error("epics: Channel Finder sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
// Convert CFS properties and tags
|
||||
props := make(map[string]string)
|
||||
for _, p := range ch.Properties {
|
||||
props[p.Name] = p.Value
|
||||
}
|
||||
tags := make([]string, len(ch.Tags))
|
||||
for i, t := range ch.Tags {
|
||||
tags[i] = t.Name
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[ch.Name]; !exists {
|
||||
e.metadata[ch.Name] = datasource.Metadata{
|
||||
Name: ch.Name,
|
||||
Properties: props,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Background pre-fetch of actual record info
|
||||
go e.prefetchPV(ctx, ch.Name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
|
||||
}
|
||||
|
||||
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
|
||||
mctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}
|
||||
|
||||
// attachCAContext attaches the saved CA context to the calling OS thread.
|
||||
// Must be called at the top of every goroutine that uses CA functions,
|
||||
// because Go goroutines can be scheduled onto any OS thread.
|
||||
@@ -340,10 +403,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)
|
||||
@@ -420,13 +483,12 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||
}
|
||||
|
||||
// ListSignals returns metadata for all currently tracked channels.
|
||||
// Channels with cached metadata return full info; others return a stub entry
|
||||
// with the PV name so the signal tree can display them immediately after
|
||||
// a manual add, before GetMetadata has been called.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||
|
||||
// Start with all fully-fetched metadata entries.
|
||||
out := make([]datasource.Metadata, 0, len(e.channels))
|
||||
seen := make(map[string]bool, len(e.metadata))
|
||||
@@ -446,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
// Write puts a new value onto a CA channel.
|
||||
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
|
||||
// a temporary CA channel is created, used for the put, then torn down.
|
||||
//
|
||||
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
|
||||
// the CGo/libca build: libca uses a single process-wide CA context whose client
|
||||
// name is fixed at startup. Writes therefore use the server identity here. Use
|
||||
// the default pure-Go build for per-user write attribution.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
e.attachCAContext()
|
||||
|
||||
@@ -581,3 +648,27 @@ func nativeTimeType(chid C.chid) C.chtype {
|
||||
return C.DBR_TIME_DOUBLE
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromArchiver(ctx context.Context) {
|
||||
time.Sleep(1 * time.Second) // wait for network
|
||||
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
|
||||
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
|
||||
if err != nil {
|
||||
slog.Error("epics: Archiver sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range pvs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[name]; !exists {
|
||||
e.metadata[name] = datasource.Metadata{Name: name}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
// Background pre-fetch of record info
|
||||
go e.prefetchPV(ctx, name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//go:build !epics
|
||||
|
||||
// Integration tests for the pure-Go EPICS datasource adapter.
|
||||
// These tests use the in-process fake CA server from the goca testca package
|
||||
// and therefore do not require a real EPICS installation.
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
// newTestDS creates a datasource.DataSource backed by a fake CA server.
|
||||
func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testca.Server) {
|
||||
t.Helper()
|
||||
srv, err := testca.New(pvs)
|
||||
if err != nil {
|
||||
t.Fatalf("testca.New: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ds := epics.New(srv.UDPAddr(), "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
return ds, srv
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetMetadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_GetMetadataDouble(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:DOUBLE")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Name != "DS:DOUBLE" {
|
||||
t.Errorf("Name = %q, want %q", meta.Name, "DS:DOUBLE")
|
||||
}
|
||||
if meta.Type != datasource.TypeFloat64 {
|
||||
t.Errorf("Type = %v, want TypeFloat64", meta.Type)
|
||||
}
|
||||
if !meta.Writable {
|
||||
t.Error("expected Writable=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataString(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:STR", DBFType: proto.DBFString, Count: 1, Value: "hello"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:STR")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Type != datasource.TypeString {
|
||||
t.Errorf("Type = %v, want TypeString", meta.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataEnum(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(0)},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:ENUM")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Type != datasource.TypeEnum {
|
||||
t.Errorf("Type = %v, want TypeEnum", meta.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataReadOnly(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:RO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
|
||||
Access: proto.AccessRead},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:RO")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Writable {
|
||||
t.Error("expected Writable=false for read-only PV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataCached(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:CACHED", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
m1, err := ds.GetMetadata(ctx, "DS:CACHED")
|
||||
if err != nil {
|
||||
t.Fatalf("first GetMetadata: %v", err)
|
||||
}
|
||||
m2, err := ds.GetMetadata(ctx, "DS:CACHED")
|
||||
if err != nil {
|
||||
t.Fatalf("second GetMetadata: %v", err)
|
||||
}
|
||||
if m1.Name != m2.Name || m1.Type != m2.Type {
|
||||
t.Error("second GetMetadata returned different result from cache")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_SubscribeInitialValue(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:SUB", DBFType: proto.DBFDouble, Count: 1, Value: 7.5},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:SUB", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain MetaUpdate and value frames — first numeric value wins.
|
||||
deadline := time.After(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if math.Abs(f-7.5) > 1e-6 {
|
||||
t.Errorf("Data = %g, want 7.5", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_SubscribeUpdates(t *testing.T) {
|
||||
ds, srv := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:UPD", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:UPD", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial frames.
|
||||
deadline := time.After(3 * time.Second)
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.MetaUpdate {
|
||||
break drainLoop
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timeout draining initial frames")
|
||||
}
|
||||
}
|
||||
|
||||
// Push new value via server.
|
||||
srv.SetValue("DS:UPD", 99.9)
|
||||
|
||||
deadline = time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if math.Abs(f-99.9) > 1e-6 {
|
||||
t.Errorf("Data = %g, want 99.9", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for updated value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Write //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_WriteDouble(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:W", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:W", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial.
|
||||
deadline := time.After(3 * time.Second)
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.MetaUpdate {
|
||||
break drainLoop
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timeout draining initial frames")
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.Write(ctx, "DS:W", float64(3.14)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
deadline = time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if math.Abs(f-3.14) > 1e-6 {
|
||||
t.Errorf("post-write Data = %g, want 3.14", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for post-write monitor update")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_WriteReadOnly(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:WRO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
|
||||
Access: proto.AccessRead},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := ds.Write(ctx, "DS:WRO", float64(1.0))
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to read-only PV")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// ListSignals //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_ListSignals(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:LS1", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
{Name: "DS:LS2", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// GetMetadata for each PV to populate the cache.
|
||||
for _, name := range []string{"DS:LS1", "DS:LS2"} {
|
||||
if _, err := ds.GetMetadata(ctx, name); err != nil {
|
||||
t.Fatalf("GetMetadata(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
sigs, err := ds.ListSignals(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSignals: %v", err)
|
||||
}
|
||||
if len(sigs) < 2 {
|
||||
t.Errorf("ListSignals returned %d signals, want ≥ 2", len(sigs))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_HistoryUnavailable(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:HIST", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := ds.History(ctx, "DS:HIST", time.Now().Add(-time.Hour), time.Now(), 100)
|
||||
if err != datasource.ErrHistoryUnavailable {
|
||||
t.Errorf("History with no archiveURL: want ErrHistoryUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,472 @@
|
||||
//go:build !epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source.
|
||||
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
|
||||
// this stub is compiled instead, so that the rest of the codebase can call
|
||||
// epics.Available() to decide whether to register the data source.
|
||||
// Package epics provides an EPICS Channel Access data source for uopi using a
|
||||
// pure-Go CA implementation (no CGo, no EPICS Base installation required).
|
||||
// When built WITH the "epics" build tag, the CGo-based implementation in
|
||||
// epics.go is used instead; that version requires CGo and a working EPICS Base.
|
||||
package epics
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog" //nolint:depguard
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// Available reports whether the EPICS Channel Access data source is compiled in.
|
||||
// It always returns false when built without the "epics" build tag.
|
||||
func Available() bool { return false }
|
||||
ca "github.com/uopi/goca"
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// New is a placeholder that returns nil when EPICS support is not compiled in.
|
||||
// Callers must check Available() before calling New().
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
|
||||
// Available always returns true for the pure-Go build.
|
||||
func Available() bool { return true }
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// EPICS data source (pure-Go implementation) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// EPICS is the pure-Go Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
|
||||
client *ca.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
metadata map[string]datasource.Metadata
|
||||
|
||||
// Per-user CA clients for writes. EPICS Channel Access carries the client
|
||||
// username at the circuit (TCP) level, so attributing a write to a specific
|
||||
// end-user requires a dedicated client whose ClientName is that user. These
|
||||
// are created lazily on first write by each user and evicted when idle.
|
||||
ctx context.Context // datasource lifetime; governs per-user clients
|
||||
baseCfg ca.Config // template config for per-user clients
|
||||
selfName string // the server's own CA client name
|
||||
userMu sync.Mutex
|
||||
userClients map[string]*userClient
|
||||
}
|
||||
|
||||
// userClient is a per-end-user CA client and its last-use time for idle eviction.
|
||||
type userClient struct {
|
||||
cli *ca.Client
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// userClientIdleTTL is how long an idle per-user CA client is kept before being
|
||||
// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
|
||||
// without holding circuits open indefinitely.
|
||||
const userClientIdleTTL = 10 * time.Minute
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
|
||||
// Leave empty to use the environment variable.
|
||||
// archiveURL is the base URL of an EPICS Archive Appliance instance; leave
|
||||
// empty to disable history queries.
|
||||
// pvNames is an optional list of PV names whose metadata will be pre-fetched
|
||||
// at connect time so they appear in ListSignals immediately (useful for the
|
||||
// edit-mode signal tree).
|
||||
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
cfURL: cfURL,
|
||||
autoSyncFilter: autoSyncFilter,
|
||||
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||
pvNames: pvNames,
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (e *EPICS) Name() string { return "epics" }
|
||||
|
||||
// Connect creates the CA client and starts background I/O goroutines.
|
||||
// ctx governs the lifetime of the client; cancel it to shut everything down.
|
||||
func (e *EPICS) Connect(ctx context.Context) error {
|
||||
cfg := ca.ConfigFromEnv()
|
||||
if e.caAddrList != "" {
|
||||
cfg.AddrList = strings.Fields(e.caAddrList)
|
||||
cfg.AutoAddrList = false
|
||||
}
|
||||
slog.Info("epics: connecting CA client", "addrs", cfg.AddrList, "auto_addr", cfg.AutoAddrList)
|
||||
cli, err := ca.NewClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epics: %w", err)
|
||||
}
|
||||
e.client = cli
|
||||
e.ctx = ctx
|
||||
e.baseCfg = cfg
|
||||
e.selfName = cfg.ClientName
|
||||
e.userClients = make(map[string]*userClient)
|
||||
go e.evictIdleUserClients(ctx)
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
|
||||
|
||||
// Perform background sync from Channel Finder if configured.
|
||||
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||
go e.syncFromChannelFinder(ctx)
|
||||
}
|
||||
|
||||
// Perform background sync from Archive Appliance if configured.
|
||||
if e.archiveURL != "" && e.autoSyncFromArchiver {
|
||||
go e.syncFromArchiver(ctx)
|
||||
}
|
||||
|
||||
// Pre-fetch metadata for any configured PV names.
|
||||
if len(e.pvNames) > 0 {
|
||||
go func() {
|
||||
for _, pv := range e.pvNames {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.prefetchPV(ctx, pv)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
|
||||
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
|
||||
if err != nil {
|
||||
slog.Error("epics: Channel Finder sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Convert CFS properties and tags to datasource.Metadata
|
||||
props := make(map[string]string)
|
||||
for _, p := range ch.Properties {
|
||||
props[p.Name] = p.Value
|
||||
}
|
||||
tags := make([]string, len(ch.Tags))
|
||||
for i, t := range ch.Tags {
|
||||
tags[i] = t.Name
|
||||
}
|
||||
|
||||
// Initialize metadata with CFS info. Full metadata (type, units, etc.)
|
||||
// will be populated on first access via DBR_CTRL GET.
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[ch.Name]; !exists {
|
||||
e.metadata[ch.Name] = datasource.Metadata{
|
||||
Name: ch.Name,
|
||||
Properties: props,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Background pre-fetch of actual record info (type, etc.)
|
||||
go e.prefetchPV(ctx, ch.Name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
|
||||
}
|
||||
|
||||
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
|
||||
tvCh := make(chan proto.TimeValue, 1)
|
||||
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer subCancel()
|
||||
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
// Channel is now connected; fetch CTRL metadata.
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Subscribe registers ch to receive live value updates for signal.
|
||||
// It blocks until the CA channel is connected (or ctx expires).
|
||||
// A background goroutine forwards proto.TimeValue updates to ch as
|
||||
// datasource.Value. Call the returned CancelFunc to unsubscribe.
|
||||
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
slog.Info("epics: subscribing", "pv", signal)
|
||||
tvCh := make(chan proto.TimeValue, 16)
|
||||
|
||||
clientCancel, err := e.client.Subscribe(ctx, signal, tvCh)
|
||||
if err != nil {
|
||||
slog.Error("epics: subscribe failed", "pv", signal, "err", err)
|
||||
return nil, fmt.Errorf("epics: subscribe %q: %w", signal, err)
|
||||
}
|
||||
slog.Info("epics: subscribed OK", "pv", signal)
|
||||
|
||||
// Fetch and cache metadata now that the channel is connected.
|
||||
// Send MetaUpdate so the dispatcher re-broadcasts metadata to clients.
|
||||
go func() {
|
||||
// Small delay gives ACCESS_RIGHTS message time to arrive before GetCtrl.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
if _, err := e.GetMetadata(mctx, signal); err == nil {
|
||||
select {
|
||||
case ch <- datasource.Value{MetaUpdate: true}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
// Forward goroutine: convert proto.TimeValue → datasource.Value.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case tv := <-tvCh:
|
||||
slog.Debug("epics: value received", "pv", signal, "double", tv.Double, "severity", tv.Severity)
|
||||
val := e.timeValueToDS(signal, tv)
|
||||
select {
|
||||
case ch <- val:
|
||||
default: // drop if consumer is slow
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancel := func() {
|
||||
clientCancel()
|
||||
close(done)
|
||||
}
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// timeValueToDS converts a proto.TimeValue to a datasource.Value.
|
||||
func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value {
|
||||
var data any
|
||||
if tv.Doubles != nil {
|
||||
if len(tv.Doubles) == 1 {
|
||||
data = tv.Doubles[0]
|
||||
} else {
|
||||
data = tv.Doubles
|
||||
}
|
||||
} else if tv.Str != "" {
|
||||
data = tv.Str
|
||||
} else {
|
||||
data = tv.Double
|
||||
}
|
||||
|
||||
quality := datasource.QualityGood
|
||||
switch tv.Severity {
|
||||
case proto.SeverityMinor:
|
||||
quality = datasource.QualityUncertain
|
||||
case proto.SeverityMajor, proto.SeverityInvalid:
|
||||
quality = datasource.QualityBad
|
||||
}
|
||||
|
||||
return datasource.Value{
|
||||
Timestamp: tv.Timestamp,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetMetadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// GetMetadata returns metadata for signal, using a cached value when available.
|
||||
// On the first call for a signal it performs a DBR_CTRL_* GET to retrieve
|
||||
// units, display limits, enum strings, and access rights.
|
||||
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
||||
e.mu.RLock()
|
||||
if m, ok := e.metadata[signal]; ok {
|
||||
e.mu.RUnlock()
|
||||
return m, nil
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
slog.Info("epics: fetching metadata", "pv", signal)
|
||||
ci, err := e.client.GetCtrl(ctx, signal)
|
||||
if err != nil {
|
||||
slog.Error("epics: GetMetadata failed", "pv", signal, "err", err)
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: GetMetadata %q: %w", signal, err)
|
||||
}
|
||||
|
||||
m := e.ctrlToMeta(signal, ci)
|
||||
slog.Info("epics: metadata fetched", "pv", signal, "type", m.Type, "unit", m.Unit, "writable", m.Writable)
|
||||
|
||||
e.mu.Lock()
|
||||
e.metadata[signal] = m
|
||||
e.mu.Unlock()
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ctrlToMeta converts a ca.CtrlInfo to a datasource.Metadata.
|
||||
func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
|
||||
m := datasource.Metadata{
|
||||
Name: name,
|
||||
Writable: ci.Access&proto.AccessWrite != 0,
|
||||
}
|
||||
|
||||
switch {
|
||||
case ci.Double != nil:
|
||||
m.Type = datasource.TypeFloat64
|
||||
if ci.Count > 1 {
|
||||
m.Type = datasource.TypeFloat64Array
|
||||
}
|
||||
m.Unit = ci.Double.Units
|
||||
m.DisplayLow = ci.Double.LowerDispLimit
|
||||
m.DisplayHigh = ci.Double.UpperDispLimit
|
||||
m.DriveLow = ci.Double.LowerCtrlLimit
|
||||
m.DriveHigh = ci.Double.UpperCtrlLimit
|
||||
|
||||
case ci.Long != nil:
|
||||
m.Type = datasource.TypeInt64
|
||||
m.Unit = ci.Long.Units
|
||||
m.DisplayLow = float64(ci.Long.LowerDispLimit)
|
||||
m.DisplayHigh = float64(ci.Long.UpperDispLimit)
|
||||
m.DriveLow = float64(ci.Long.LowerCtrlLimit)
|
||||
m.DriveHigh = float64(ci.Long.UpperCtrlLimit)
|
||||
|
||||
case ci.Enum != nil:
|
||||
m.Type = datasource.TypeEnum
|
||||
m.EnumStrings = ci.Enum.Strings
|
||||
|
||||
case ci.Str != nil:
|
||||
m.Type = datasource.TypeString
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// ListSignals //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// ListSignals returns metadata for all signals with cached information.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||
for _, m := range e.metadata {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Write //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Write puts a new value onto the named CA channel.
|
||||
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
|
||||
//
|
||||
// When the request context carries an end-user identity (see datasource.WithUser)
|
||||
// that differs from the server's own CA client name, the put is performed on a
|
||||
// dedicated CA client whose ClientName is that user, so the IOC's access-security
|
||||
// rules see the actual end-user rather than the server process.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
cli := e.client
|
||||
if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
|
||||
uc, err := e.clientForUser(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
|
||||
}
|
||||
cli = uc
|
||||
}
|
||||
if err := cli.Put(ctx, signal, value); err != nil {
|
||||
return fmt.Errorf("epics: write %q: %w", signal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientForUser returns a CA client whose ClientName is user, creating and
|
||||
// caching one on first use. The client is bound to the datasource lifetime ctx,
|
||||
// not the per-request ctx, so it survives across writes until evicted when idle.
|
||||
func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
|
||||
e.userMu.Lock()
|
||||
defer e.userMu.Unlock()
|
||||
if uc, ok := e.userClients[user]; ok {
|
||||
uc.lastUsed = time.Now()
|
||||
return uc.cli, nil
|
||||
}
|
||||
cfg := e.baseCfg
|
||||
cfg.ClientName = user
|
||||
cli, err := ca.NewClient(e.ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
|
||||
slog.Info("epics: created per-user CA client", "user", user)
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// evictIdleUserClients closes per-user CA clients that have not been used within
|
||||
// userClientIdleTTL. It runs until ctx is cancelled.
|
||||
func (e *EPICS) evictIdleUserClients(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
e.userMu.Lock()
|
||||
for user, uc := range e.userClients {
|
||||
if now.Sub(uc.lastUsed) > userClientIdleTTL {
|
||||
uc.cli.Close()
|
||||
delete(e.userClients, user)
|
||||
slog.Info("epics: evicted idle per-user CA client", "user", user)
|
||||
}
|
||||
}
|
||||
e.userMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// History delegates to the Archive Appliance client, or returns
|
||||
// ErrHistoryUnavailable if no archive URL is configured.
|
||||
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
|
||||
if e.archiveURL == "" {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromArchiver(ctx context.Context) {
|
||||
time.Sleep(1 * time.Second) // wait for network
|
||||
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
|
||||
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
|
||||
if err != nil {
|
||||
slog.Error("epics: Archiver sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range pvs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[name]; !exists {
|
||||
e.metadata[name] = datasource.Metadata{Name: name}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
// Background pre-fetch of record info
|
||||
go e.prefetchPV(ctx, name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ type Metadata struct {
|
||||
DriveHigh float64
|
||||
EnumStrings []string // non-nil only for TypeEnum
|
||||
Writable bool
|
||||
Properties map[string]string // Channel Finder properties
|
||||
Tags []string // Channel Finder tags
|
||||
}
|
||||
|
||||
// CancelFunc cancels a subscription started by DataSource.Subscribe.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// Package pva provides an EPICS PV Access data source for uopi using the
|
||||
// pure-Go gopva library.
|
||||
package pva
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gopva "github.com/uopi/gopva"
|
||||
"github.com/uopi/gopva/pvdata"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// PVA is the PV Access data source.
|
||||
type PVA struct {
|
||||
client *gopva.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
metadata map[string]datasource.Metadata
|
||||
}
|
||||
|
||||
// New creates a new PV Access data source.
|
||||
// Server addresses are read from the EPICS_PVA_ADDR_LIST environment variable;
|
||||
// addrList overrides that when non-empty.
|
||||
func New(addrList []string) datasource.DataSource {
|
||||
if len(addrList) > 0 {
|
||||
// Inject addresses into the environment so gopva.NewClient picks them up.
|
||||
_ = os.Setenv("EPICS_PVA_ADDR_LIST", strings.Join(addrList, " "))
|
||||
}
|
||||
return &PVA{
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (p *PVA) Name() string { return "pva" }
|
||||
|
||||
// Connect creates the PVA client.
|
||||
func (p *PVA) Connect(_ context.Context) error {
|
||||
cl, err := gopva.NewClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pva: connect: %w", err)
|
||||
}
|
||||
p.client = cl
|
||||
slog.Info("pva: client started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Subscribe registers ch to receive live PVA value updates for signal.
|
||||
func (p *PVA) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
slog.Info("pva: subscribing", "pv", signal)
|
||||
|
||||
monCh := p.client.Monitor(ctx, signal)
|
||||
|
||||
// Wait for the first event to confirm the subscription is live.
|
||||
// We do this in the goroutine to avoid blocking the caller.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for evt := range monCh {
|
||||
if evt.Err != nil {
|
||||
slog.Error("pva: monitor error", "pv", signal, "err", evt.Err)
|
||||
select {
|
||||
case ch <- datasource.Value{
|
||||
Timestamp: time.Now(),
|
||||
Data: nil,
|
||||
Quality: datasource.QualityBad,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
// Cache metadata from the first value.
|
||||
p.updateMetadata(signal, evt.Value)
|
||||
|
||||
val := structToValue(evt.Value)
|
||||
select {
|
||||
case ch <- val:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancel := func() {
|
||||
// cancelling the context passed to Monitor stops it; the channel will be closed.
|
||||
// The goroutine will exit when monCh is closed.
|
||||
<-done
|
||||
}
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetMetadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// GetMetadata returns metadata for signal. It performs a one-shot GET if
|
||||
// metadata has not been cached yet.
|
||||
func (p *PVA) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
||||
p.mu.RLock()
|
||||
if m, ok := p.metadata[signal]; ok {
|
||||
p.mu.RUnlock()
|
||||
return m, nil
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
tctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sv, err := p.client.Get(tctx, signal)
|
||||
if err != nil {
|
||||
return datasource.Metadata{}, fmt.Errorf("pva: GetMetadata %q: %w", signal, err)
|
||||
}
|
||||
|
||||
p.updateMetadata(signal, sv)
|
||||
|
||||
p.mu.RLock()
|
||||
m := p.metadata[signal]
|
||||
p.mu.RUnlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateMetadata derives and caches metadata from a StructValue.
|
||||
func (p *PVA) updateMetadata(signal string, sv pvdata.StructValue) {
|
||||
m := structToMeta(signal, sv)
|
||||
p.mu.Lock()
|
||||
p.metadata[signal] = m
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// ListSignals //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// ListSignals returns metadata for all signals with cached information.
|
||||
func (p *PVA) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]datasource.Metadata, 0, len(p.metadata))
|
||||
for _, m := range p.metadata {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Write //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Write is not yet implemented for PVA.
|
||||
func (p *PVA) Write(_ context.Context, _ string, _ any) error {
|
||||
return fmt.Errorf("pva: write not yet implemented")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// History is not supported for PVA sources.
|
||||
func (p *PVA) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// structToValue converts a PVA StructValue to a datasource.Value.
|
||||
// It follows NTScalar/NTScalarArray conventions: looks for a "value" field
|
||||
// and an optional "alarm" struct for quality, and "timeStamp" for the timestamp.
|
||||
func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||
val := fieldByName(sv, "value")
|
||||
ts := extractTimestamp(sv)
|
||||
quality := extractQuality(sv)
|
||||
|
||||
var data any
|
||||
if val != nil {
|
||||
data = normaliseValue(val)
|
||||
}
|
||||
|
||||
return datasource.Value{
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
}
|
||||
}
|
||||
|
||||
// structToMeta derives datasource.Metadata from a PVA StructValue.
|
||||
func structToMeta(name string, sv pvdata.StructValue) datasource.Metadata {
|
||||
m := datasource.Metadata{
|
||||
Name: name,
|
||||
Writable: true, // PVA channels are assumed writable until proven otherwise
|
||||
}
|
||||
|
||||
val := fieldByName(sv, "value")
|
||||
if val == nil && len(sv.Fields) > 0 {
|
||||
val = sv.Fields[0].Value
|
||||
}
|
||||
|
||||
if val != nil {
|
||||
switch val.(type) {
|
||||
case float64, float32:
|
||||
m.Type = datasource.TypeFloat64
|
||||
case int64, int32, int16, int8, uint64, uint32, uint16, uint8:
|
||||
m.Type = datasource.TypeInt64
|
||||
case string:
|
||||
m.Type = datasource.TypeString
|
||||
case bool:
|
||||
m.Type = datasource.TypeBool
|
||||
case []float64, []float32:
|
||||
m.Type = datasource.TypeFloat64Array
|
||||
default:
|
||||
m.Type = datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
// Extract display limits from NTScalar display structure.
|
||||
if disp := structByName(sv, "display"); disp != nil {
|
||||
if v := fieldByName(*disp, "limitLow"); v != nil {
|
||||
m.DisplayLow, _ = toFloat64(v)
|
||||
}
|
||||
if v := fieldByName(*disp, "limitHigh"); v != nil {
|
||||
m.DisplayHigh, _ = toFloat64(v)
|
||||
}
|
||||
if v := fieldByName(*disp, "units"); v != nil {
|
||||
m.Unit, _ = v.(string)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract control limits from NTScalar control structure.
|
||||
if ctrl := structByName(sv, "control"); ctrl != nil {
|
||||
if v := fieldByName(*ctrl, "limitLow"); v != nil {
|
||||
m.DriveLow, _ = toFloat64(v)
|
||||
}
|
||||
if v := fieldByName(*ctrl, "limitHigh"); v != nil {
|
||||
m.DriveHigh, _ = toFloat64(v)
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// normaliseValue converts any PVA scalar/array value to datasource-compatible types.
|
||||
func normaliseValue(v any) any {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int32:
|
||||
return int64(x)
|
||||
case int16:
|
||||
return int64(x)
|
||||
case int8:
|
||||
return int64(x)
|
||||
case uint64:
|
||||
return int64(x)
|
||||
case uint32:
|
||||
return int64(x)
|
||||
case uint16:
|
||||
return int64(x)
|
||||
case uint8:
|
||||
return int64(x)
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return x
|
||||
case []float64:
|
||||
return x
|
||||
case []float32:
|
||||
out := make([]float64, len(x))
|
||||
for i, f := range x {
|
||||
out[i] = float64(f)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func extractTimestamp(sv pvdata.StructValue) time.Time {
|
||||
ts := structByName(sv, "timeStamp")
|
||||
if ts == nil {
|
||||
return time.Now()
|
||||
}
|
||||
sec := int64(0)
|
||||
nsec := int32(0)
|
||||
if v := fieldByName(*ts, "secondsPastEpoch"); v != nil {
|
||||
sec, _ = v.(int64)
|
||||
}
|
||||
if v := fieldByName(*ts, "nanoseconds"); v != nil {
|
||||
nsec, _ = v.(int32)
|
||||
}
|
||||
if sec == 0 {
|
||||
return time.Now()
|
||||
}
|
||||
// EPICS epoch: 1990-01-01 00:00:00 UTC = Unix 631152000
|
||||
const epicsEpoch = 631152000
|
||||
return time.Unix(sec+epicsEpoch, int64(nsec)).UTC()
|
||||
}
|
||||
|
||||
func extractQuality(sv pvdata.StructValue) datasource.Quality {
|
||||
alarm := structByName(sv, "alarm")
|
||||
if alarm == nil {
|
||||
return datasource.QualityGood
|
||||
}
|
||||
sev := fieldByName(*alarm, "severity")
|
||||
if sev == nil {
|
||||
return datasource.QualityGood
|
||||
}
|
||||
s, _ := sev.(int32)
|
||||
switch s {
|
||||
case 1:
|
||||
return datasource.QualityUncertain
|
||||
case 2, 3:
|
||||
return datasource.QualityBad
|
||||
default:
|
||||
return datasource.QualityGood
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func fieldByName(sv pvdata.StructValue, name string) any {
|
||||
for _, f := range sv.Fields {
|
||||
if f.Name == name {
|
||||
return f.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
|
||||
for _, f := range sv.Fields {
|
||||
if f.Name == name {
|
||||
if s, ok := f.Value.(pvdata.StructValue); ok {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package stub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
@@ -13,10 +14,11 @@ import (
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const updateInterval = 100 * time.Millisecond // 10 Hz
|
||||
const updateInterval = 100 * time.Millisecond // 10 Hz default
|
||||
|
||||
type signalDef struct {
|
||||
meta datasource.Metadata
|
||||
interval time.Duration // 0 → updateInterval
|
||||
fn func(t time.Time) any
|
||||
}
|
||||
|
||||
@@ -96,6 +98,41 @@ func New() *Stub {
|
||||
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
|
||||
})
|
||||
s.values["setpoint"] = 25.0
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "counter_fast", Type: datasource.TypeFloat64,
|
||||
Description: "Fast monotonic counter (1 ms tick) for benchmarks",
|
||||
},
|
||||
interval: time.Millisecond,
|
||||
fn: func(t time.Time) any { return float64(t.UnixMilli()) },
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// NewN creates a Stub with n dynamically generated signals named "pv_0" … "pv_{n-1}".
|
||||
// Each signal is a sine wave at a distinct frequency, emitted at 10 Hz.
|
||||
// Use this for stress and scale testing.
|
||||
func NewN(n int) *Stub {
|
||||
s := &Stub{
|
||||
signals: make(map[string]signalDef),
|
||||
values: make(map[string]any),
|
||||
}
|
||||
for i := range n {
|
||||
freq := 0.1 + float64(i)*0.01 // spread frequencies so signals differ
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: fmt.Sprintf("pv_%d", i),
|
||||
Type: datasource.TypeFloat64,
|
||||
Unit: "V",
|
||||
DisplayLow: -1,
|
||||
DisplayHigh: 1,
|
||||
Description: fmt.Sprintf("Synthetic PV #%d (%.2f Hz sine)", i, freq),
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
return math.Sin(2 * math.Pi * freq * float64(t.UnixNano()) / 1e9)
|
||||
},
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -127,16 +164,22 @@ func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadat
|
||||
return d.meta, nil
|
||||
}
|
||||
|
||||
// Subscribe starts a 10 Hz ticker that pushes generated values into ch.
|
||||
// Subscribe starts a ticker that pushes generated values into ch.
|
||||
// The tick interval is the signal's configured interval (default 10 Hz).
|
||||
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
def, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
interval := def.interval
|
||||
if interval == 0 {
|
||||
interval = updateInterval
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(updateInterval)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package stub_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
func newStub(t *testing.T) *stub.Stub {
|
||||
t.Helper()
|
||||
s := stub.New()
|
||||
if err := s.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
if stub.New().Name() != "stub" {
|
||||
t.Error("Name() should return 'stub'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSignals(t *testing.T) {
|
||||
s := newStub(t)
|
||||
sigs, err := s.ListSignals(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListSignals: %v", err)
|
||||
}
|
||||
if len(sigs) == 0 {
|
||||
t.Fatal("expected at least one signal")
|
||||
}
|
||||
names := make(map[string]bool)
|
||||
for _, m := range sigs {
|
||||
names[m.Name] = true
|
||||
}
|
||||
for _, want := range []string{"sine_1hz", "ramp_10s", "toggle_1hz", "noise", "setpoint"} {
|
||||
if !names[want] {
|
||||
t.Errorf("signal %q not in ListSignals", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetadata(t *testing.T) {
|
||||
s := newStub(t)
|
||||
m, err := s.GetMetadata(context.Background(), "sine_1hz")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if m.Type != datasource.TypeFloat64 {
|
||||
t.Errorf("Type = %v, want TypeFloat64", m.Type)
|
||||
}
|
||||
if m.Unit == "" {
|
||||
t.Error("expected non-empty Unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetadataNotFound(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.GetMetadata(context.Background(), "no:such:pv")
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReceivesValues(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "sine_1hz", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("Quality = %v, want Good", v.Quality)
|
||||
}
|
||||
if _, ok := v.Data.(float64); !ok {
|
||||
t.Errorf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if v.Timestamp.IsZero() {
|
||||
t.Error("Timestamp is zero")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for first value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUnknown(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.Subscribe(context.Background(), "no:such", make(chan datasource.Value, 1))
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeCancel(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "ramp_10s", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
// Drain one value to confirm it's running.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for first value")
|
||||
}
|
||||
|
||||
stop() // cancel subscription
|
||||
|
||||
// Drain remaining buffered values then confirm channel goes quiet.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
for len(ch) > 0 {
|
||||
<-ch
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if len(ch) > 0 {
|
||||
t.Error("channel still receiving after stop()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetpoint(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Write(context.Background(), "setpoint", float64(99.0)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "setpoint", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if f != 99.0 {
|
||||
t.Errorf("setpoint value = %g, want 99.0", f)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for setpoint value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadOnly(t *testing.T) {
|
||||
s := newStub(t)
|
||||
err := s.Write(context.Background(), "sine_1hz", float64(0))
|
||||
if err != datasource.ErrNotWritable {
|
||||
t.Errorf("want ErrNotWritable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNotFound(t *testing.T) {
|
||||
s := newStub(t)
|
||||
err := s.Write(context.Background(), "no:such", float64(0))
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.History(context.Background(), "sine_1hz", time.Now().Add(-time.Hour), time.Now(), 100)
|
||||
if err != datasource.ErrHistoryUnavailable {
|
||||
t.Errorf("want ErrHistoryUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBool(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "toggle_1hz", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if _, ok := v.Data.(bool); !ok {
|
||||
t.Errorf("toggle_1hz Data type = %T, want bool", v.Data)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ type SignalDef struct {
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
// "global" — listed in every panel's edit mode
|
||||
// "user" — listed in every panel owned by Owner
|
||||
// "panel" — listed only when editing the bound Panel
|
||||
// An empty value is treated as "global" for backward compatibility with
|
||||
// definitions created before this field existed.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
}
|
||||
|
||||
// InputRef names one upstream signal used as input to the pipeline.
|
||||
|
||||
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "derivative":
|
||||
return &dsp.DerivativeNode{}, nil
|
||||
|
||||
case "integrate":
|
||||
return &dsp.IntegrateNode{}, nil
|
||||
|
||||
case "clamp":
|
||||
return &dsp.ClampNode{
|
||||
Min: floatParam(p, "min"),
|
||||
@@ -99,6 +102,16 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "expr":
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||
|
||||
case "lowpass":
|
||||
order := int(floatParam(p, "order"))
|
||||
if order < 1 {
|
||||
order = 1
|
||||
}
|
||||
return &dsp.LowPassNode{
|
||||
Freq: floatParam(p, "freq"),
|
||||
Order: order,
|
||||
}, nil
|
||||
|
||||
case "lua":
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||
|
||||
|
||||
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FilteredMetadata returns metadata for every defined synthetic signal for
|
||||
// which keep returns true. It lets the API layer apply per-caller visibility
|
||||
// rules (which depend on SignalDef fields not present in datasource.Metadata).
|
||||
func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
if keep(st.def) {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for a single named signal.
|
||||
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
s.mu.RLock()
|
||||
@@ -286,6 +302,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()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package datasource
|
||||
|
||||
import "context"
|
||||
|
||||
// userKey is the unexported context key under which the end-user identity is
|
||||
// stored. Using a private type prevents collisions with other packages.
|
||||
type userKey struct{}
|
||||
|
||||
// WithUser returns a copy of ctx carrying the end-user identity associated with
|
||||
// the request (e.g. the authenticated web client). Data sources may use this to
|
||||
// attribute operations to the actual user rather than the server process — for
|
||||
// example EPICS Channel Access access-security rules match on the client
|
||||
// username. An empty user is treated as "no client identity".
|
||||
func WithUser(ctx context.Context, user string) context.Context {
|
||||
if user == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, userKey{}, user)
|
||||
}
|
||||
|
||||
// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean
|
||||
// is false when no (non-empty) identity is present, in which case callers
|
||||
// should fall back to the server's own identity.
|
||||
func UserFrom(ctx context.Context) (string, bool) {
|
||||
u, ok := ctx.Value(userKey{}).(string)
|
||||
return u, ok && u != ""
|
||||
}
|
||||
+239
-32
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ── IntegrateNode ─────────────────────────────────────────────────────────────
|
||||
|
||||
// IntegrateNode accumulates the time-integral of input[0] using the trapezoidal
|
||||
// rule, where dt is in seconds. On the first call it returns 0 (no interval yet).
|
||||
type IntegrateNode struct{}
|
||||
|
||||
func (n *IntegrateNode) Type() string { return "integrate" }
|
||||
func (n *IntegrateNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("integrate: no inputs")
|
||||
}
|
||||
now := time.Now()
|
||||
acc, _ := state["acc"].(float64)
|
||||
if prevVal, ok := state["prev_val"].(float64); ok {
|
||||
if prevTime, ok := state["prev_time"].(time.Time); ok {
|
||||
dt := now.Sub(prevTime).Seconds()
|
||||
if dt < 0 {
|
||||
dt = 0
|
||||
}
|
||||
acc += (inputs[0] + prevVal) * 0.5 * dt
|
||||
}
|
||||
}
|
||||
state["acc"] = acc
|
||||
state["prev_val"] = inputs[0]
|
||||
state["prev_time"] = now
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// ── ClampNode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ClampNode clamps the output to [Min, Max].
|
||||
@@ -277,12 +305,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)*
|
||||
// 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 +365,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 +375,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 +391,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 a–d 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 +529,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 +544,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)
|
||||
@@ -418,6 +563,68 @@ func (p *exprParser) parseFactor() (float64, error) {
|
||||
return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos)
|
||||
}
|
||||
|
||||
// ── LowPassNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// LowPassNode implements a cascaded first-order IIR (RC) low-pass filter.
|
||||
// The filter coefficient α is recomputed each sample from the elapsed time so
|
||||
// it is correct for event-driven (non-fixed-rate) signals.
|
||||
// For Order > 1 the single-pole stage is cascaded Order times, approximating
|
||||
// a Butterworth response with -20·Order dB/decade roll-off.
|
||||
type LowPassNode struct {
|
||||
Freq float64 // cutoff frequency in Hz (–3 dB point)
|
||||
Order int // number of cascaded stages (1–8)
|
||||
}
|
||||
|
||||
func (n *LowPassNode) Type() string { return "lowpass" }
|
||||
|
||||
func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("lowpass: no inputs")
|
||||
}
|
||||
x := inputs[0]
|
||||
now := time.Now()
|
||||
|
||||
order := n.Order
|
||||
if order < 1 {
|
||||
order = 1
|
||||
}
|
||||
if order > 8 {
|
||||
order = 8
|
||||
}
|
||||
fc := n.Freq
|
||||
if fc <= 0 {
|
||||
fc = 1.0
|
||||
}
|
||||
|
||||
prevT, hasT := state["t"].(time.Time)
|
||||
if !hasT {
|
||||
// First sample: prime all stage states with the input value.
|
||||
for i := range order {
|
||||
state[fmt.Sprintf("y%d", i)] = x
|
||||
}
|
||||
state["t"] = now
|
||||
return x, nil
|
||||
}
|
||||
|
||||
dt := now.Sub(prevT).Seconds()
|
||||
state["t"] = now
|
||||
if dt <= 0 {
|
||||
dt = 1e-9
|
||||
}
|
||||
|
||||
rc := 1.0 / (2.0 * math.Pi * fc)
|
||||
alpha := dt / (rc + dt)
|
||||
|
||||
y := x
|
||||
for i := range order {
|
||||
key := fmt.Sprintf("y%d", i)
|
||||
prev, _ := state[key].(float64)
|
||||
y = alpha*y + (1-alpha)*prev
|
||||
state[key] = y
|
||||
}
|
||||
return y, nil
|
||||
}
|
||||
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
)
|
||||
|
||||
func TestCounters(t *testing.T) {
|
||||
// Counters are package-level atomics; exercise each Inc function.
|
||||
metrics.IncWsConns()
|
||||
metrics.IncWsConns()
|
||||
metrics.DecWsConns()
|
||||
metrics.IncMsgIn()
|
||||
metrics.IncMsgOut()
|
||||
metrics.IncWrites()
|
||||
metrics.IncHistoryReqs()
|
||||
// No panic = pass; values are additive across tests but that's fine.
|
||||
}
|
||||
|
||||
func TestHandlerFormat(t *testing.T) {
|
||||
handler := metrics.Handler(func() int { return 42 })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
|
||||
// Must contain all metric names.
|
||||
required := []string{
|
||||
"uopi_uptime_seconds",
|
||||
"uopi_ws_connections",
|
||||
"uopi_signal_subscriptions",
|
||||
"uopi_ws_messages_in_total",
|
||||
"uopi_ws_messages_out_total",
|
||||
"uopi_write_ops_total",
|
||||
"uopi_history_requests_total",
|
||||
}
|
||||
for _, name := range required {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("metric %q not found in response body", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type must be set for Prometheus scrapers.
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/plain") {
|
||||
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||
}
|
||||
|
||||
// activeSubs value 42 must appear.
|
||||
if !strings.Contains(body, "42") {
|
||||
t.Error("activeSubs value 42 not found in body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNilActiveSubs(t *testing.T) {
|
||||
handler := metrics.Handler(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req) // must not panic
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a
|
||||
// nested folder hierarchy for organising panels (Phase 3). It persists a single
|
||||
// sidecar index, {storageDir}/acl.json, alongside the XML interface files.
|
||||
//
|
||||
// The effective permission a user has on a panel is the maximum of: ownership
|
||||
// (always write), the panel's public level, any direct user grant, any grant to
|
||||
// a user-group the user belongs to, and the permissions inherited from the
|
||||
// folder chain the panel lives in. Callers are expected to further cap this by
|
||||
// the user's global access level (see internal/access).
|
||||
//
|
||||
// Panels that have no record in the index are treated as fully open
|
||||
// (PermWrite). This preserves access to interfaces created before ACLs existed;
|
||||
// panels created through the ACL-aware API are recorded as private to their
|
||||
// owner.
|
||||
package panelacl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a folder does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Perm is an effective access level on a panel or folder.
|
||||
type Perm int
|
||||
|
||||
const (
|
||||
// PermNone denies access.
|
||||
PermNone Perm = iota
|
||||
// PermRead permits viewing only.
|
||||
PermRead
|
||||
// PermWrite permits viewing and modification.
|
||||
PermWrite
|
||||
)
|
||||
|
||||
// String renders a Perm as the token used in the JSON index and HTTP API.
|
||||
func (p Perm) String() string {
|
||||
switch p {
|
||||
case PermRead:
|
||||
return "read"
|
||||
case PermWrite:
|
||||
return "write"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone.
|
||||
func parsePerm(s string) Perm {
|
||||
switch s {
|
||||
case "read":
|
||||
return PermRead
|
||||
case "write", "readwrite", "rw":
|
||||
return PermWrite
|
||||
default:
|
||||
return PermNone
|
||||
}
|
||||
}
|
||||
|
||||
// Grant shares a panel or folder with a single user or a named user-group.
|
||||
type Grant struct {
|
||||
Kind string `json:"kind"` // "user" | "group"
|
||||
Name string `json:"name"` // username or user-group name
|
||||
Perm string `json:"perm"` // "read" | "write"
|
||||
}
|
||||
|
||||
// PanelACL is the ownership + sharing record for one panel.
|
||||
type PanelACL struct {
|
||||
Owner string `json:"owner"`
|
||||
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
|
||||
Public string `json:"public,omitempty"` // "" | "read" | "write"
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
}
|
||||
|
||||
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
|
||||
// folder are inherited by every panel and subfolder beneath it.
|
||||
type Folder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Parent string `json:"parent,omitempty"` // parent folder id ("" = root)
|
||||
Owner string `json:"owner"`
|
||||
Public string `json:"public,omitempty"`
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
type index struct {
|
||||
Panels map[string]*PanelACL `json:"panels"`
|
||||
Folders map[string]*Folder `json:"folders"`
|
||||
}
|
||||
|
||||
// Store persists and resolves the panel ACL index. It is safe for concurrent
|
||||
// use.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
idx index
|
||||
}
|
||||
|
||||
// New opens (loading if present) the acl.json index in storageDir.
|
||||
func New(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, "acl.json"),
|
||||
idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}},
|
||||
}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.idx.Panels == nil {
|
||||
s.idx.Panels = map[string]*PanelACL{}
|
||||
}
|
||||
if s.idx.Folders == nil {
|
||||
s.idx.Folders = map[string]*Folder{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// saveLocked atomically persists the index. The caller must hold s.mu.
|
||||
func (s *Store) saveLocked() error {
|
||||
data, err := json.MarshalIndent(s.idx, "", " ")
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Panel records ────────────────────────────────────────────────────────────
|
||||
|
||||
// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged.
|
||||
func (s *Store) GetPanel(id string) *PanelACL {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
acl, ok := s.idx.Panels[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return clonePanel(acl)
|
||||
}
|
||||
|
||||
// CreatePanel records ownership for a newly created panel, defaulting to
|
||||
// private (owner-only) access. Existing records are left untouched.
|
||||
func (s *Store) CreatePanel(id, owner string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.idx.Panels[id]; ok {
|
||||
return nil
|
||||
}
|
||||
s.idx.Panels[id] = &PanelACL{Owner: owner}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// SetPanel replaces a panel's sharing settings (folder, public level, grants)
|
||||
// while preserving its existing owner (or adopting the supplied owner when the
|
||||
// panel was previously unmanaged).
|
||||
func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
acl := s.idx.Panels[id]
|
||||
if acl == nil {
|
||||
acl = &PanelACL{Owner: owner}
|
||||
s.idx.Panels[id] = acl
|
||||
}
|
||||
acl.Folder = folder
|
||||
acl.Public = public
|
||||
acl.Grants = grants
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// PlacePanel sets only a panel's organizational fields — its folder and sort
|
||||
// order — preserving ownership and sharing. A previously-unmanaged panel gets a
|
||||
// record with no owner, which PanelPerm still treats as fully open (so dragging
|
||||
// a legacy panel into a folder does not lock it).
|
||||
func (s *Store) PlacePanel(id, folder string, order float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
acl := s.idx.Panels[id]
|
||||
if acl == nil {
|
||||
acl = &PanelACL{}
|
||||
s.idx.Panels[id] = acl
|
||||
}
|
||||
acl.Folder = folder
|
||||
acl.Order = order
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// DeletePanel removes a panel's ACL record (called when the panel is deleted).
|
||||
func (s *Store) DeletePanel(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.idx.Panels[id]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.idx.Panels, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// ── Folders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Folders returns a copy of every folder, keyed by id.
|
||||
func (s *Store) Folders() map[string]Folder {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[string]Folder, len(s.idx.Folders))
|
||||
for id, f := range s.idx.Folders {
|
||||
out[id] = *cloneFolder(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetFolder returns a copy of a folder, or ErrNotFound.
|
||||
func (s *Store) GetFolder(id string) (Folder, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return Folder{}, ErrNotFound
|
||||
}
|
||||
return *cloneFolder(f), nil
|
||||
}
|
||||
|
||||
// CreateFolder adds a new folder owned by owner and returns it.
|
||||
func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if parent != "" {
|
||||
if _, ok := s.idx.Folders[parent]; !ok {
|
||||
return Folder{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner}
|
||||
s.idx.Folders[id] = f
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return Folder{}, err
|
||||
}
|
||||
return *cloneFolder(f), nil
|
||||
}
|
||||
|
||||
// UpdateFolder replaces a folder's mutable fields (name, parent, public,
|
||||
// grants). It rejects parent changes that would create a cycle.
|
||||
func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if parent != "" {
|
||||
if _, ok := s.idx.Folders[parent]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.createsCycleLocked(id, parent) {
|
||||
return errors.New("folder parent change would create a cycle")
|
||||
}
|
||||
}
|
||||
f.Name = name
|
||||
f.Parent = parent
|
||||
f.Public = public
|
||||
f.Grants = grants
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// DeleteFolder removes a folder, reparenting its child folders and panels to the
|
||||
// deleted folder's parent (so nothing is orphaned).
|
||||
func (s *Store) DeleteFolder(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
newParent := f.Parent
|
||||
for _, child := range s.idx.Folders {
|
||||
if child.Parent == id {
|
||||
child.Parent = newParent
|
||||
}
|
||||
}
|
||||
for _, acl := range s.idx.Panels {
|
||||
if acl.Folder == id {
|
||||
acl.Folder = newParent
|
||||
}
|
||||
}
|
||||
delete(s.idx.Folders, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// createsCycleLocked reports whether setting folder id's parent to newParent
|
||||
// would introduce a cycle. The caller must hold s.mu.
|
||||
func (s *Store) createsCycleLocked(id, newParent string) bool {
|
||||
for cur := newParent; cur != ""; {
|
||||
if cur == id {
|
||||
return true
|
||||
}
|
||||
f, ok := s.idx.Folders[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = f.Parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Permission resolution ────────────────────────────────────────────────────
|
||||
|
||||
// PanelPerm returns the effective permission user has on panel id, given the
|
||||
// user-groups the user belongs to. Unmanaged panels are fully open.
|
||||
func (s *Store) PanelPerm(id, user string, groups []string) Perm {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
acl, ok := s.idx.Panels[id]
|
||||
if !ok {
|
||||
return PermWrite // legacy/unmanaged panel: fully open
|
||||
}
|
||||
// A record carrying only organizational metadata (folder/order) with no
|
||||
// owner, public level, or grants is still open — keeps an unmanaged panel
|
||||
// accessible after it has merely been moved into a folder or reordered.
|
||||
if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 {
|
||||
return PermWrite
|
||||
}
|
||||
best := PermNone
|
||||
if user != "" && acl.Owner == user {
|
||||
return PermWrite
|
||||
}
|
||||
best = maxPerm(best, parsePerm(acl.Public))
|
||||
best = maxPerm(best, grantsPerm(acl.Grants, user, groups))
|
||||
if acl.Folder != "" {
|
||||
best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{}))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// FolderPerm returns the effective permission user has on folder id.
|
||||
func (s *Store) FolderPerm(id, user string, groups []string) Perm {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.folderPermLocked(id, user, groups, map[string]bool{})
|
||||
}
|
||||
|
||||
func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm {
|
||||
if id == "" || seen[id] {
|
||||
return PermNone
|
||||
}
|
||||
seen[id] = true
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return PermNone
|
||||
}
|
||||
if user != "" && f.Owner == user {
|
||||
return PermWrite
|
||||
}
|
||||
best := PermNone
|
||||
best = maxPerm(best, parsePerm(f.Public))
|
||||
best = maxPerm(best, grantsPerm(f.Grants, user, groups))
|
||||
best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen))
|
||||
return best
|
||||
}
|
||||
|
||||
func grantsPerm(grants []Grant, user string, groups []string) Perm {
|
||||
best := PermNone
|
||||
for _, g := range grants {
|
||||
switch g.Kind {
|
||||
case "user":
|
||||
if user != "" && g.Name == user {
|
||||
best = maxPerm(best, parsePerm(g.Perm))
|
||||
}
|
||||
case "group":
|
||||
if contains(groups, g.Name) {
|
||||
best = maxPerm(best, parsePerm(g.Perm))
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func maxPerm(a, b Perm) Perm {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func contains(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clonePanel(a *PanelACL) *PanelACL {
|
||||
c := *a
|
||||
c.Grants = append([]Grant(nil), a.Grants...)
|
||||
return &c
|
||||
}
|
||||
|
||||
func cloneFolder(f *Folder) *Folder {
|
||||
c := *f
|
||||
c.Grants = append([]Grant(nil), f.Grants...)
|
||||
return &c
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package panelacl
|
||||
|
||||
import "testing"
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("New:", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestUnmanagedPanelIsOpen(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("unmanaged panel perm = %v, want write", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerAlwaysWrites(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.PanelPerm("p1", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("owner perm = %v, want write", got)
|
||||
}
|
||||
// A freshly created panel is private to its owner.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermNone {
|
||||
t.Fatalf("non-owner perm on private panel = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAndGrants(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants := []Grant{
|
||||
{Kind: "user", Name: "bob", Perm: "write"},
|
||||
{Kind: "group", Name: "ops", Perm: "read"},
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Public read applies to anyone.
|
||||
if got := s.PanelPerm("p1", "carol", nil); got != PermRead {
|
||||
t.Fatalf("public perm = %v, want read", got)
|
||||
}
|
||||
// Direct user grant raises bob to write.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermWrite {
|
||||
t.Fatalf("granted user perm = %v, want write", got)
|
||||
}
|
||||
// Group membership grants read.
|
||||
if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead {
|
||||
t.Fatalf("group perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderInheritance(t *testing.T) {
|
||||
s := newStore(t)
|
||||
f, err := s.CreateFolder("f1", "Shared", "", "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The panel inherits the folder's public-read level.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermRead {
|
||||
t.Fatalf("inherited perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCycleRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
// Re-parenting A under its own descendant B must be rejected.
|
||||
if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil {
|
||||
t.Fatal("expected cycle rejection, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFolderReparents(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteFolder(b.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// B's panel should reparent to A (B's former parent), not orphan.
|
||||
if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID {
|
||||
t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
@@ -23,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, channelFinderURL 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
|
||||
@@ -33,13 +37,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
|
||||
// REST API
|
||||
api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix)
|
||||
// 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, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
@@ -56,6 +63,44 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
}
|
||||
}
|
||||
|
||||
// accessMiddleware resolves the end-user identity from the trusted proxy header
|
||||
// (falling back to the configured default_user), stores it on the request
|
||||
// context, and enforces the user's global access level on every API request:
|
||||
// - LevelNone → 403 for all requests.
|
||||
// - LevelRead → 403 for any mutating request (non GET/HEAD).
|
||||
// - LevelWrite → no restriction.
|
||||
//
|
||||
// The /me endpoint is always reachable so the frontend can discover the
|
||||
// caller's identity and level even when otherwise restricted.
|
||||
func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var raw string
|
||||
if userHeader != "" {
|
||||
raw = r.Header.Get(userHeader)
|
||||
}
|
||||
user := policy.ResolveUser(raw)
|
||||
r = r.WithContext(access.WithUser(r.Context(), user))
|
||||
|
||||
// Always allow identity discovery.
|
||||
if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch policy.Level(user) {
|
||||
case access.LevelNone:
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return
|
||||
case access.LevelRead:
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "read-only access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
|
||||
+47
-8
@@ -6,11 +6,13 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
@@ -73,6 +75,8 @@ type metaPayload struct {
|
||||
DriveHigh float64 `json:"driveHigh,omitempty"`
|
||||
EnumStrings []string `json:"enumStrings,omitempty"`
|
||||
Writable bool `json:"writable"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type histPoint struct {
|
||||
@@ -85,9 +89,21 @@ type histPoint struct {
|
||||
type wsHandler struct {
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
// userHeader is the HTTP header from which the per-session end-user identity
|
||||
// is read (set by a trusted auth proxy). Empty disables per-user identity.
|
||||
userHeader string
|
||||
// policy enforces the global access level (blacklist) on signal writes.
|
||||
policy *access.Policy
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture the end-user identity from the trusted proxy header (if enabled)
|
||||
// before the connection is upgraded, while the original headers are present.
|
||||
var user string
|
||||
if h.userHeader != "" {
|
||||
user = strings.TrimSpace(r.Header.Get(h.userHeader))
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
|
||||
InsecureSkipVerify: true,
|
||||
@@ -105,8 +121,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c := &wsClient{
|
||||
conn: conn,
|
||||
broker: h.broker,
|
||||
outCh: make(chan []byte, 128),
|
||||
updateCh: make(chan broker.Update, 256),
|
||||
user: user,
|
||||
policy: h.policy,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
log: h.log,
|
||||
}
|
||||
@@ -115,7 +133,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.
|
||||
@@ -134,6 +152,8 @@ type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
policy *access.Policy // global access-level enforcement
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -193,7 +213,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 +274,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +295,14 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
|
||||
}
|
||||
|
||||
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
// Enforce the user's global access level: blacklisted read-only / no-access
|
||||
// users may not write signals, regardless of the channel's own writability.
|
||||
if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite {
|
||||
c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user)
|
||||
c.sendError(ctx, "ACCESS_DENIED", "you do not have write access")
|
||||
return
|
||||
}
|
||||
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
if !ok {
|
||||
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
|
||||
@@ -297,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
|
||||
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user)
|
||||
metrics.IncWrites()
|
||||
// Attribute the write to the connecting end-user so data sources (EPICS) can
|
||||
// act under that identity rather than the server's.
|
||||
ctx = datasource.WithUser(ctx, c.user)
|
||||
if err := ds.Write(ctx, msg.Name, value); err != nil {
|
||||
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
|
||||
c.sendError(ctx, "WRITE_ERROR", err.Error())
|
||||
@@ -341,7 +378,7 @@ func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
b, _ := json.Marshal(resp)
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +427,8 @@ func metadataToPayload(m datasource.Metadata) *metaPayload {
|
||||
DriveHigh: m.DriveHigh,
|
||||
EnumStrings: m.EnumStrings,
|
||||
Writable: m.Writable,
|
||||
Properties: m.Properties,
|
||||
Tags: m.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
// newStressServer creates an httptest.Server backed by a stub with n signals.
|
||||
// The server and broker contexts are tied to t.Context() and cleaned up automatically.
|
||||
func newStressServer(t *testing.T, nSignals int) (*httptest.Server, *broker.Broker) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
var ds *stub.Stub
|
||||
if nSignals > 0 {
|
||||
ds = stub.NewN(nSignals)
|
||||
} else {
|
||||
ds = stub.New()
|
||||
}
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(ds)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, brk
|
||||
}
|
||||
|
||||
// wsURL converts an http:// test server URL to ws://.
|
||||
func wsURL(srv *httptest.Server) string {
|
||||
return strings.ReplaceAll(srv.URL, "http://", "ws://") + "/ws"
|
||||
}
|
||||
|
||||
// buildSubMsg encodes a subscribe message for n stub PVs named "pv_0"…"pv_{n-1}".
|
||||
func buildSubMsg(n int) []byte {
|
||||
sigs := make([]sigRef, n)
|
||||
for i := range n {
|
||||
sigs[i] = sigRef{DS: "stub", Name: fmt.Sprintf("pv_%d", i)}
|
||||
}
|
||||
data, _ := json.Marshal(inMsg{Type: "subscribe", Signals: sigs})
|
||||
return data
|
||||
}
|
||||
|
||||
// connectAndCount dials the WebSocket, sends subMsg, counts incoming messages until
|
||||
// ctx is cancelled, adds the count to *total, and signals wg.Done.
|
||||
func connectAndCount(ctx context.Context, t *testing.T, url string, subMsg []byte, total *atomic.Int64, wg *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
defer wg.Done()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(ctx, websocket.MessageText, subMsg); err != nil {
|
||||
t.Errorf("subscribe write: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var n int64
|
||||
for {
|
||||
_, _, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
total.Add(n)
|
||||
}
|
||||
|
||||
// TestStress_100PVs_10Clients verifies the full stack (broker + WS handler) can
|
||||
// deliver updates from 100 signals to 10 concurrent clients without loss or deadlock.
|
||||
func TestStress_100PVs_10Clients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 100
|
||||
nClients = 10
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var total atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range nClients {
|
||||
wg.Add(1)
|
||||
// Each client runs for exactly `duration`, then its context expires and
|
||||
// conn.Read returns an error, causing the goroutine to exit cleanly.
|
||||
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer clientCancel()
|
||||
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
got := total.Load()
|
||||
// At 10 Hz, each PV fires ≥ duration*10 times; each client receives all of them.
|
||||
// min = nClients × nSignals × duration_s × 10 × 0.5 (conservative — meta msgs too)
|
||||
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 5
|
||||
t.Logf("100PVs/10Clients: received %d updates (min expected %d)", got, minExpected)
|
||||
if got < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
|
||||
}
|
||||
|
||||
// Broker must release all upstream subscriptions after clients disconnect.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_500PVs_20Clients is the full-scale scenario: hundreds of signals,
|
||||
// tens of clients. Verifies no crash, no deadlock, and clean teardown.
|
||||
func TestStress_500PVs_20Clients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 500
|
||||
nClients = 20
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var total atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range nClients {
|
||||
wg.Add(1)
|
||||
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer clientCancel()
|
||||
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
got := total.Load()
|
||||
// Conservative: expect at least 20% of the theoretical maximum
|
||||
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 2
|
||||
t.Logf("500PVs/20Clients: received %d updates (min expected %d)", got, minExpected)
|
||||
if got < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_ClientChurn repeatedly connects and disconnects clients while a
|
||||
// stable set of signals is being delivered to ensure subscribe/unsubscribe under
|
||||
// load doesn't cause races, panics, or subscription leaks.
|
||||
func TestStress_ClientChurn(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 50
|
||||
nParallelConns = 30
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
u := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
deadline := time.Now().Add(duration)
|
||||
var wg sync.WaitGroup
|
||||
var total atomic.Int64
|
||||
|
||||
for range nParallelConns {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for time.Now().Before(deadline) {
|
||||
// Each iteration: one short-lived connection (~500 ms).
|
||||
func() {
|
||||
connCtx, connCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer connCancel()
|
||||
|
||||
conn, _, err := websocket.Dial(connCtx, u, nil)
|
||||
if err != nil {
|
||||
return // dial failed (server busy) — try next iteration
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(connCtx, websocket.MessageText, subMsg); err != nil {
|
||||
return
|
||||
}
|
||||
var n int64
|
||||
for {
|
||||
_, _, err := conn.Read(connCtx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
total.Add(n)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("ClientChurn: %d total messages during %s of churn with %d concurrent connections",
|
||||
total.Load(), duration, nParallelConns)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak after churn: %d remain", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_SlowConsumer verifies that a slow-reading client doesn't block
|
||||
// fast clients or the broker fan-out (drop-on-full behaviour).
|
||||
func TestStress_SlowConsumer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 100
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var fastTotal, slowTotal atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Fast client: reads as quickly as possible.
|
||||
wg.Add(1)
|
||||
fastCtx, fastCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer fastCancel()
|
||||
connectAndCount(fastCtx, t, url, subMsg, &fastTotal, &wg)
|
||||
}()
|
||||
|
||||
// Slow client: sleeps 100 ms between each read.
|
||||
wg.Add(1)
|
||||
slowCtx, slowCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer slowCancel()
|
||||
|
||||
conn, _, err := websocket.Dial(slowCtx, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("slow client dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(slowCtx, websocket.MessageText, subMsg); err != nil {
|
||||
t.Errorf("slow client subscribe: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var n int64
|
||||
for {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _, err := conn.Read(slowCtx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
slowTotal.Add(n)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
fast := fastTotal.Load()
|
||||
slow := slowTotal.Load()
|
||||
t.Logf("SlowConsumer: fast=%d updates, slow=%d updates in %s", fast, slow, duration)
|
||||
|
||||
// Fast client must receive substantially more than slow client.
|
||||
if fast < slow*3 {
|
||||
t.Errorf("fast client should receive many more updates than slow client (fast=%d slow=%d)", fast, slow)
|
||||
}
|
||||
|
||||
// Fast client must have received a meaningful number of updates.
|
||||
minFast := int64(nSignals) * int64(duration.Seconds()) * 5
|
||||
if fast < minFast {
|
||||
t.Errorf("fast client received too few updates: got %d, want >= %d", fast, minFast)
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain", n)
|
||||
}
|
||||
}
|
||||
+344
-12
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -22,9 +24,20 @@ type InterfaceMeta struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
|
||||
// VersionMeta describes a single persisted revision of an interface.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
// Store persists interface definitions as XML files under {storageDir}/interfaces/
|
||||
// and workspace-level JSON blobs directly in {storageDir}.
|
||||
type Store struct {
|
||||
dir string
|
||||
rootDir string // {storageDir} — for workspace-level files (groups.json, etc.)
|
||||
dir string // {storageDir}/interfaces
|
||||
}
|
||||
|
||||
// New opens (and creates, if needed) the storage directory.
|
||||
@@ -33,7 +46,22 @@ func New(storageDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create interfaces dir: %w", err)
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
return &Store{rootDir: storageDir, dir: dir}, nil
|
||||
}
|
||||
|
||||
// ReadGroups returns the raw JSON array of group tree nodes.
|
||||
// Returns an empty JSON array if no groups have been saved yet.
|
||||
func (s *Store) ReadGroups() ([]byte, error) {
|
||||
data, err := os.ReadFile(filepath.Join(s.rootDir, "groups.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// WriteGroups persists the group tree. data must be a valid JSON array.
|
||||
func (s *Store) WriteGroups(data []byte) error {
|
||||
return os.WriteFile(filepath.Join(s.rootDir, "groups.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// validateID returns an error if id could be used for path traversal or is
|
||||
@@ -63,10 +91,16 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
||||
}
|
||||
var out []InterfaceMeta
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".xml") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(e.Name(), ".xml")
|
||||
// Skip versioned backups: id.v1.xml, id.v2.xml, etc.
|
||||
if isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
meta, err := s.readMeta(id)
|
||||
if err != nil {
|
||||
continue // skip corrupt files silently
|
||||
@@ -79,12 +113,27 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isVersioned(name string) bool {
|
||||
// Format: <id>.v<number>.xml
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
vPart := parts[1]
|
||||
if !strings.HasPrefix(vPart, "v") {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(vPart[1:])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// rootAttrs is used to parse only the top-level XML attributes.
|
||||
type rootAttrs struct {
|
||||
XMLName xml.Name `xml:"interface"`
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Version int `xml:"version,attr"`
|
||||
Tag string `xml:"tag,attr"`
|
||||
}
|
||||
|
||||
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
@@ -113,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) {
|
||||
|
||||
// Create stores new interface XML and returns the generated ID.
|
||||
// The ID is derived from the name attribute; a timestamp suffix is added to
|
||||
// ensure uniqueness.
|
||||
func (s *Store) Create(xmlData []byte) (string, error) {
|
||||
// ensure uniqueness. If tag is non-empty it is stamped as the revision label.
|
||||
func (s *Store) Create(xmlData []byte, tag string) (string, error) {
|
||||
var root rootAttrs
|
||||
if err := xml.Unmarshal(xmlData, &root); err != nil {
|
||||
return "", fmt.Errorf("invalid XML: %w", err)
|
||||
@@ -128,18 +177,301 @@ func (s *Store) Create(xmlData []byte) (string, error) {
|
||||
if _, err := os.Stat(s.filePath(id)); err == nil {
|
||||
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
}
|
||||
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||
|
||||
// Initialize version to 1 and stamp the generated ID so the persisted XML
|
||||
// is self-consistent (the client sends an empty id for new interfaces).
|
||||
updatedData, err := setXMLAttribute(xmlData, "version", "1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("initialize version attribute: %w", err)
|
||||
}
|
||||
updatedData, err = setXMLAttribute(updatedData, "id", id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stamp id attribute: %w", err)
|
||||
}
|
||||
if tag != "" {
|
||||
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update replaces the XML for an existing interface.
|
||||
func (s *Store) Update(id string, xmlData []byte) error {
|
||||
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
|
||||
}
|
||||
|
||||
// Update replaces the XML for an existing interface, preserving the previous
|
||||
// version as a separate file. If tag is non-empty it is stamped as the label
|
||||
// for the new revision.
|
||||
func (s *Store) Update(id string, xmlData []byte, tag string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
oldPath := s.filePath(id)
|
||||
oldData, err := os.ReadFile(oldPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse current version to name the backup file.
|
||||
var oldRoot rootAttrs
|
||||
if err := xml.Unmarshal(oldData, &oldRoot); err != nil {
|
||||
return fmt.Errorf("parse existing XML: %w", err)
|
||||
}
|
||||
if oldRoot.Version < 1 {
|
||||
oldRoot.Version = 1
|
||||
}
|
||||
|
||||
// Move old file to backup path: id.vN.xml
|
||||
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version))
|
||||
if err := os.WriteFile(backupPath, oldData, 0o644); err != nil {
|
||||
return fmt.Errorf("create backup: %w", err)
|
||||
}
|
||||
|
||||
// Update version in new data.
|
||||
newVersion := oldRoot.Version + 1
|
||||
updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update version attribute: %w", err)
|
||||
}
|
||||
if tag != "" {
|
||||
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(oldPath, updatedData, 0o644)
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision of the interface,
|
||||
// ordered newest-first. The current file is flagged with Current=true.
|
||||
func (s *Store) Versions(id string) ([]VersionMeta, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Current revision.
|
||||
curInfo, err := os.Stat(s.filePath(id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curRoot, err := s.readRoot(s.filePath(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []VersionMeta{{
|
||||
Version: curRoot.Version,
|
||||
Name: curRoot.Name,
|
||||
Tag: curRoot.Tag,
|
||||
Current: true,
|
||||
SavedAt: curInfo.ModTime(),
|
||||
}}
|
||||
|
||||
// Backup revisions: id.vN.xml
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
|
||||
continue
|
||||
}
|
||||
if !isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(s.dir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
root, err := s.readRoot(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{
|
||||
Version: root.Version,
|
||||
Name: root.Name,
|
||||
Tag: root.Tag,
|
||||
SavedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns the raw XML bytes for a specific revision of an interface.
|
||||
func (s *Store) GetVersion(id string, version int) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// The current file may hold the requested version.
|
||||
curRoot, err := s.readRoot(s.filePath(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if curRoot.Version == version {
|
||||
return os.ReadFile(s.filePath(id))
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// versionPath returns the on-disk path for a specific revision, resolving the
|
||||
// current file when version matches the live revision.
|
||||
func (s *Store) versionPath(id string, version int) (string, error) {
|
||||
cur := s.filePath(id)
|
||||
curRoot, err := s.readRoot(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if curRoot.Version == version {
|
||||
return cur, nil
|
||||
}
|
||||
backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// SetVersionTag sets (or clears, when tag is empty) the label of a specific
|
||||
// revision in place, without creating a new revision.
|
||||
func (s *Store) SetVersionTag(id string, version int, tag string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
path, err := s.versionPath(id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := setXMLAttribute(data, "tag", tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, updated, 0o644)
|
||||
}
|
||||
|
||||
// Promote makes a past revision the current one by re-saving its content as a
|
||||
// new revision on top of history. The existing current revision is preserved
|
||||
// as a backup, so promotion is non-destructive.
|
||||
func (s *Store) Promote(id string, version int) error {
|
||||
data, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
|
||||
}
|
||||
|
||||
// Fork creates a brand-new interface from the content of a specific revision,
|
||||
// assigning a fresh unique ID and resetting its version to 1.
|
||||
func (s *Store) Fork(id string, version int) (string, error) {
|
||||
data, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
updated, err := setXMLAttribute(data, "version", "1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reset version attribute: %w", err)
|
||||
}
|
||||
updated, err = setXMLAttribute(updated, "id", newID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stamp id attribute: %w", err)
|
||||
}
|
||||
updated, err = setXMLAttribute(updated, "tag", "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clear tag attribute: %w", err)
|
||||
}
|
||||
return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
|
||||
}
|
||||
|
||||
// readRoot parses the top-level interface attributes from the file at path.
|
||||
func (s *Store) readRoot(path string) (rootAttrs, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return rootAttrs{}, err
|
||||
}
|
||||
var root rootAttrs
|
||||
if err := xml.Unmarshal(data, &root); err != nil {
|
||||
return rootAttrs{}, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// setXMLAttribute is a primitive helper to update a top-level attribute in an
|
||||
// XML blob without full unmarshal/marshal (which might mess up formatting).
|
||||
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
|
||||
// Very basic implementation: look for the attribute in the first 512 bytes.
|
||||
// A proper implementation would use an XML encoder or a better regex.
|
||||
s := string(data)
|
||||
|
||||
// Locate the root element's opening tag, skipping any XML declaration
|
||||
// (<?xml ... ?>), comments, or processing instructions. Operating on the
|
||||
// first '>' in the document would match the '?>' of the XML declaration and
|
||||
// corrupt its version attribute (producing invalid XML).
|
||||
tagStart := -1
|
||||
for i := 0; i+1 < len(s); i++ {
|
||||
if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
|
||||
tagStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tagStart == -1 {
|
||||
return nil, errors.New("malformed XML: no root element")
|
||||
}
|
||||
rel := strings.Index(s[tagStart:], ">")
|
||||
if rel == -1 {
|
||||
return nil, errors.New("malformed XML: no root tag end")
|
||||
}
|
||||
tagEnd := tagStart + rel
|
||||
rootTag := s[tagStart:tagEnd]
|
||||
|
||||
attrSearch := " " + attr + "=\""
|
||||
idx := strings.Index(rootTag, attrSearch)
|
||||
if idx == -1 {
|
||||
// Attribute not found, insert it before the tag ends.
|
||||
insertIdx := tagEnd
|
||||
if s[tagEnd-1] == '/' {
|
||||
insertIdx-- // handle <tag />
|
||||
}
|
||||
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
|
||||
}
|
||||
|
||||
// Attribute found, replace its value (idx is relative to rootTag).
|
||||
valStart := tagStart + idx + len(attrSearch)
|
||||
valEnd := strings.Index(s[valStart:], "\"")
|
||||
if valEnd == -1 {
|
||||
return nil, errors.New("malformed XML: attribute value not closed")
|
||||
}
|
||||
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
|
||||
}
|
||||
|
||||
// Delete removes the interface with the given ID.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
const sampleXML = `<interface id="test-if" name="Test Interface" version="1"><widget/></interface>`
|
||||
|
||||
func newStore(t *testing.T) *storage.Store {
|
||||
t.Helper()
|
||||
s, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// List //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestListEmpty(t *testing.T) {
|
||||
s := newStore(t)
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("expected empty list, got %d items", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAfterCreate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Create([]byte(sampleXML), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(list))
|
||||
}
|
||||
if list[0].Name != "Test Interface" {
|
||||
t.Errorf("Name = %q, want %q", list[0].Name, "Test Interface")
|
||||
}
|
||||
if list[0].Version != 1 {
|
||||
t.Errorf("Version = %d, want 1", list[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Create + Get //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("Create returned empty ID")
|
||||
}
|
||||
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", id, err)
|
||||
}
|
||||
if string(data) != sampleXML {
|
||||
t.Errorf("Get returned %q, want %q", data, sampleXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUsesEmbeddedID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// ID derived from xml id attr or name slug.
|
||||
if id == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id1, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
id2, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("second Create: %v", err)
|
||||
}
|
||||
if id1 == id2 {
|
||||
t.Errorf("duplicate Create returned same ID %q", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateInvalidXML(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Create([]byte("not xml"), "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid XML")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Get errors //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("does-not-exist")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInvalidID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("../traversal")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get path-traversal ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEmptyID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get empty ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Update //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
|
||||
if err := s.Update(id, []byte(updated), ""); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after update: %v", err)
|
||||
}
|
||||
if string(data) != updated {
|
||||
t.Errorf("post-update content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
err := s.Update("no-such-id", []byte(sampleXML), "")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Update missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Delete //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := s.Delete(id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
_, err = s.Get(id)
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
err := s.Delete("ghost")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Clone //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
newID, err := s.Clone(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Clone: %v", err)
|
||||
}
|
||||
if newID == id {
|
||||
t.Error("Clone should produce a different ID")
|
||||
}
|
||||
orig, _ := s.Get(id)
|
||||
copy, _ := s.Get(newID)
|
||||
if string(orig) != string(copy) {
|
||||
t.Error("Clone content should match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Clone("ghost")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Clone missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Config //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Config holds the configuration for a Client.
|
||||
// All fields are optional; sensible defaults are applied by NewClient.
|
||||
type Config struct {
|
||||
// AddrList is the list of CA server addresses ("host" or "host:port").
|
||||
// Corresponds to EPICS_CA_ADDR_LIST.
|
||||
AddrList []string
|
||||
|
||||
// AutoAddrList, when true, appends the IPv4 broadcast address of every
|
||||
// local network interface to AddrList. Defaults to true.
|
||||
// Corresponds to EPICS_CA_AUTO_ADDR_LIST.
|
||||
AutoAddrList bool
|
||||
|
||||
// ClientName is announced to every CA server in the CLIENT_NAME message.
|
||||
// Defaults to the executable base name.
|
||||
ClientName string
|
||||
|
||||
// HostName is announced to every CA server in the HOST_NAME message.
|
||||
// Defaults to the OS hostname.
|
||||
HostName string
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads the standard EPICS CA environment variables and returns
|
||||
// a ready-to-use Config.
|
||||
//
|
||||
// EPICS_CA_ADDR_LIST — space-separated list of server addresses
|
||||
// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses
|
||||
func ConfigFromEnv() Config {
|
||||
// CA security ACLs match on the client username, not the program name.
|
||||
// Use the OS username (same as libca), falling back to $USER, then "user".
|
||||
clientName := "user"
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
clientName = u.Username
|
||||
} else if v := os.Getenv("USER"); v != "" {
|
||||
clientName = v
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
cfg := Config{
|
||||
AutoAddrList: true,
|
||||
ClientName: clientName,
|
||||
HostName: host,
|
||||
}
|
||||
if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.AddrList = strings.Fields(v)
|
||||
}
|
||||
if strings.EqualFold(os.Getenv("EPICS_CA_AUTO_ADDR_LIST"), "no") {
|
||||
cfg.AutoAddrList = false
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Client //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Client is a thread-safe EPICS Channel Access client.
|
||||
//
|
||||
// A single Client should serve an entire application. It maintains a pool of
|
||||
// persistent TCP circuits (one per IOC) and a shared UDP search engine.
|
||||
// Channels and subscriptions survive IOC restarts automatically.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv())
|
||||
// defer cli.Close()
|
||||
//
|
||||
// ch := make(chan proto.TimeValue, 16)
|
||||
// cancel, err := cli.Subscribe(ctx, "MY:PV", ch)
|
||||
// defer cancel()
|
||||
//
|
||||
// for tv := range ch { fmt.Println(tv.Double) }
|
||||
type Client struct {
|
||||
cfg Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
search *searchEngine
|
||||
|
||||
mu sync.Mutex
|
||||
circuits map[string]*circuit // IOC TCP addr → circuit
|
||||
}
|
||||
|
||||
// NewClient creates a new CA client and starts background I/O.
|
||||
// ctx governs the lifetime of the client; cancelling it is equivalent to
|
||||
// calling Close.
|
||||
//
|
||||
// Returns an error only if no search addresses can be derived from cfg.
|
||||
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
||||
addrs := resolveAddrs(cfg.AddrList, proto.DefaultPort)
|
||||
if cfg.AutoAddrList {
|
||||
addrs = append(addrs, localBroadcastAddrs(proto.DefaultPort)...)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("ca: no search addresses (set EPICS_CA_ADDR_LIST or enable AutoAddrList)")
|
||||
}
|
||||
|
||||
if cfg.ClientName == "" {
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
cfg.ClientName = u.Username
|
||||
} else if v := os.Getenv("USER"); v != "" {
|
||||
cfg.ClientName = v
|
||||
} else {
|
||||
cfg.ClientName = "user"
|
||||
}
|
||||
}
|
||||
if cfg.HostName == "" {
|
||||
cfg.HostName, _ = os.Hostname()
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
se := newSearchEngine(addrs)
|
||||
if err := se.start(cctx); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
ctx: cctx,
|
||||
cancel: cancel,
|
||||
search: se,
|
||||
circuits: make(map[string]*circuit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close shuts down all circuits and background goroutines.
|
||||
// Any in-flight Subscribe, Get, or Put calls will unblock with an error.
|
||||
func (c *Client) Close() {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Internal helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// getOrCreateCircuit returns the circuit for addr, creating one if necessary.
|
||||
func (c *Client) getOrCreateCircuit(addr string) *circuit {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if circ, ok := c.circuits[addr]; ok {
|
||||
return circ
|
||||
}
|
||||
circ := newCircuit(c.ctx, addr, c.cfg.ClientName, c.cfg.HostName)
|
||||
c.circuits[addr] = circ
|
||||
return circ
|
||||
}
|
||||
|
||||
// resolve finds the IOC for pvName via UDP search, then waits for the TCP
|
||||
// channel to be fully established. Returns (circuit, chanState) on success.
|
||||
func (c *Client) resolve(ctx context.Context, pvName string) (*circuit, *chanState, error) {
|
||||
addr, err := c.search.lookup(ctx, pvName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
circ := c.getOrCreateCircuit(addr)
|
||||
cs := circ.getOrCreateChannel(pvName)
|
||||
if err := cs.waitReady(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("ca: %q: %w", pvName, err)
|
||||
}
|
||||
return circ, cs, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Public API //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Subscribe registers ch to receive live monitor updates for pvName.
|
||||
//
|
||||
// Values are pushed as proto.TimeValue structs (DBR_TIME_* encoding).
|
||||
// ch should be buffered; slow consumers will have updates silently dropped.
|
||||
//
|
||||
// The returned CancelFunc unsubscribes from the PV and must always be called
|
||||
// to avoid leaking resources.
|
||||
//
|
||||
// Subscribe blocks until the channel is connected or ctx expires.
|
||||
func (c *Client) Subscribe(ctx context.Context, pvName string, ch chan<- proto.TimeValue) (context.CancelFunc, error) {
|
||||
circ, cs, err := c.resolve(ctx, pvName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
dbfType := cs.dbfType
|
||||
count := cs.count
|
||||
cs.mu.RUnlock()
|
||||
|
||||
ms := &monState{
|
||||
subID: circ.nextID(),
|
||||
dbrType: proto.NativeTimeType(dbfType, int(count)),
|
||||
count: count,
|
||||
ch: ch,
|
||||
}
|
||||
circ.addMonitor(cs, ms)
|
||||
|
||||
return func() { circ.removeMonitor(cs, ms.subID) }, nil
|
||||
}
|
||||
|
||||
// Get performs a one-shot READ_NOTIFY for pvName and returns its current value.
|
||||
// It blocks until the reply arrives or ctx expires.
|
||||
func (c *Client) Get(ctx context.Context, pvName string) (proto.TimeValue, error) {
|
||||
circ, cs, err := c.resolve(ctx, pvName)
|
||||
if err != nil {
|
||||
return proto.TimeValue{}, err
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
dbfType := cs.dbfType
|
||||
count := cs.count
|
||||
cs.mu.RUnlock()
|
||||
|
||||
dbrType := proto.NativeTimeType(dbfType, int(count))
|
||||
return circ.get(ctx, cs, dbrType, count)
|
||||
}
|
||||
|
||||
// Put writes value to pvName using CA_PROTO_WRITE (fire-and-forget).
|
||||
//
|
||||
// value is automatically encoded to match the PV's native field type.
|
||||
// Supported Go types: float64, float32, int64, int32, int, int16, string, bool.
|
||||
//
|
||||
// Put blocks only until the message is queued; it does not wait for an IOC
|
||||
// acknowledgement. Use a timeout context to bound the wait for connection.
|
||||
func (c *Client) Put(ctx context.Context, pvName string, value any) error {
|
||||
circ, cs, err := c.resolve(ctx, pvName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
dbfType := cs.dbfType
|
||||
cs.mu.RUnlock()
|
||||
|
||||
dbrType, payload, err := encodePut(dbfType, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: put %q: %w", pvName, err)
|
||||
}
|
||||
return circ.put(ctx, cs, dbrType, payload)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Control metadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// CtrlInfo holds the full control-block metadata for a PV.
|
||||
// Exactly one of the Double, Long, Enum, Str pointer fields is non-nil,
|
||||
// depending on the PV's native field type.
|
||||
type CtrlInfo struct {
|
||||
DBFType int // proto.DBF* constant
|
||||
Count uint32 // element count
|
||||
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
|
||||
|
||||
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
|
||||
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
|
||||
Enum *proto.CtrlEnum // non-nil for DBFEnum
|
||||
Str *proto.CtrlString // non-nil for DBFString
|
||||
}
|
||||
|
||||
// GetCtrl performs a READ_NOTIFY with a DBR_CTRL_* type and returns the full
|
||||
// control-block metadata (units, display limits, enum strings, etc.) for pvName.
|
||||
//
|
||||
// GetCtrl blocks until the reply arrives or ctx expires.
|
||||
func (c *Client) GetCtrl(ctx context.Context, pvName string) (CtrlInfo, error) {
|
||||
circ, cs, err := c.resolve(ctx, pvName)
|
||||
if err != nil {
|
||||
return CtrlInfo{}, err
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
dbfType := cs.dbfType
|
||||
count := cs.count
|
||||
access := cs.access
|
||||
cs.mu.RUnlock()
|
||||
|
||||
ctrlType := proto.NativeCtrlType(dbfType)
|
||||
_, payload, err := circ.getRaw(ctx, cs, ctrlType, count)
|
||||
if err != nil {
|
||||
return CtrlInfo{}, err
|
||||
}
|
||||
|
||||
ci := CtrlInfo{DBFType: dbfType, Count: count, Access: access}
|
||||
|
||||
switch ctrlType {
|
||||
case proto.DBRCtrlDouble:
|
||||
cd, ok := proto.DecodeCtrlDouble(payload)
|
||||
if !ok {
|
||||
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlDouble failed (payload len %d)", pvName, len(payload))
|
||||
}
|
||||
ci.Double = &cd
|
||||
|
||||
case proto.DBRCtrlLong:
|
||||
cl, ok := proto.DecodeCtrlLong(payload)
|
||||
if !ok {
|
||||
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlLong failed (payload len %d)", pvName, len(payload))
|
||||
}
|
||||
ci.Long = &cl
|
||||
|
||||
case proto.DBRCtrlEnum:
|
||||
ce, ok := proto.DecodeCtrlEnum(payload)
|
||||
if !ok {
|
||||
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlEnum failed (payload len %d)", pvName, len(payload))
|
||||
}
|
||||
ci.Enum = &ce
|
||||
|
||||
case proto.DBRCtrlString:
|
||||
cs2, ok := proto.DecodeCtrlString(payload)
|
||||
if !ok {
|
||||
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlString failed (payload len %d)", pvName, len(payload))
|
||||
}
|
||||
ci.Str = &cs2
|
||||
|
||||
default:
|
||||
return CtrlInfo{}, fmt.Errorf("ca: %q: unhandled ctrl type %d", pvName, ctrlType)
|
||||
}
|
||||
|
||||
return ci, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Value encoding for Put //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// encodePut converts a Go value to a DBR wire payload matching the PV's
|
||||
// native field type.
|
||||
func encodePut(dbfType int, value any) (dbrType uint16, payload []byte, err error) {
|
||||
switch dbfType {
|
||||
case proto.DBFDouble, proto.DBFFloat:
|
||||
f, e := toFloat64(value)
|
||||
if e != nil {
|
||||
return 0, nil, e
|
||||
}
|
||||
return proto.DBRDouble, proto.EncodeDouble(f), nil
|
||||
|
||||
case proto.DBFLong:
|
||||
n, e := toInt32(value)
|
||||
if e != nil {
|
||||
return 0, nil, e
|
||||
}
|
||||
return proto.DBRLong, proto.EncodeLong(n), nil
|
||||
|
||||
case proto.DBFShort, proto.DBFChar, proto.DBFEnum:
|
||||
n, e := toInt32(value)
|
||||
if e != nil {
|
||||
return 0, nil, e
|
||||
}
|
||||
return proto.DBRShort, proto.EncodeShort(int16(n)), nil
|
||||
|
||||
case proto.DBFString:
|
||||
s, e := toString(value)
|
||||
if e != nil {
|
||||
return 0, nil, e
|
||||
}
|
||||
return proto.DBRString, proto.EncodeString(s), nil
|
||||
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("unsupported DBF type %d", dbfType)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, error) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, nil
|
||||
case float32:
|
||||
return float64(x), nil
|
||||
case int:
|
||||
return float64(x), nil
|
||||
case int64:
|
||||
return float64(x), nil
|
||||
case int32:
|
||||
return float64(x), nil
|
||||
case int16:
|
||||
return float64(x), nil
|
||||
case uint64:
|
||||
return float64(x), nil
|
||||
case uint32:
|
||||
return float64(x), nil
|
||||
case bool:
|
||||
if x {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot convert %T to float64", v)
|
||||
}
|
||||
}
|
||||
|
||||
func toInt32(v any) (int32, error) {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return int32(x), nil
|
||||
case int64:
|
||||
return int32(x), nil
|
||||
case int32:
|
||||
return x, nil
|
||||
case int16:
|
||||
return int32(x), nil
|
||||
case float64:
|
||||
return int32(x), nil
|
||||
case float32:
|
||||
return int32(x), nil
|
||||
case bool:
|
||||
if x {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot convert %T to int32", v)
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v any) (string, error) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x, nil
|
||||
case []byte:
|
||||
return string(x), nil
|
||||
default:
|
||||
return fmt.Sprintf("%v", x), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// newTestClient creates a Client pointing only at the fake server's addresses.
|
||||
func newTestClient(t *testing.T, srv *testca.Server) *ca.Client {
|
||||
t.Helper()
|
||||
cfg := ca.Config{
|
||||
AddrList: []string{srv.UDPAddr()},
|
||||
AutoAddrList: false,
|
||||
ClientName: "testclient",
|
||||
HostName: "localhost",
|
||||
}
|
||||
cli, err := ca.NewClient(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
t.Cleanup(cli.Close)
|
||||
return cli
|
||||
}
|
||||
|
||||
// newTestServer creates a fake CA server with standard test PVs.
|
||||
func newTestServer(t *testing.T) *testca.Server {
|
||||
t.Helper()
|
||||
srv, err := testca.New([]testca.PVSpec{
|
||||
{Name: "TEST:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 3.14},
|
||||
{Name: "TEST:LONG", DBFType: proto.DBFLong, Count: 1, Value: int32(42)},
|
||||
{Name: "TEST:STRING", DBFType: proto.DBFString, Count: 1, Value: "hello"},
|
||||
{Name: "TEST:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(1)},
|
||||
{Name: "TEST:READONLY", DBFType: proto.DBFDouble, Count: 1, Value: 1.0,
|
||||
Access: proto.AccessRead},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("testca.New: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Get tests //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetDouble(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tv, err := cli.Get(ctx, "TEST:DOUBLE")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if math.Abs(tv.Double-3.14) > 1e-6 {
|
||||
t.Errorf("Double = %g, want ~3.14", tv.Double)
|
||||
}
|
||||
if tv.Timestamp.IsZero() {
|
||||
t.Error("Timestamp is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLong(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tv, err := cli.Get(ctx, "TEST:LONG")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if tv.Long != 42 {
|
||||
t.Errorf("Long = %d, want 42", tv.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetString(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tv, err := cli.Get(ctx, "TEST:STRING")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if tv.Str != "hello" {
|
||||
t.Errorf("Str = %q, want %q", tv.Str, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := cli.Get(ctx, "NO:SUCH:PV")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing PV, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe tests //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestSubscribeReceivesInitialValue(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-3.14) > 1e-6 {
|
||||
t.Errorf("initial Double = %g, want ~3.14", tv.Double)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial monitor value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReceivesUpdates(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial value.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
|
||||
// Push a new value from the server side.
|
||||
if err := srv.SetValue("TEST:DOUBLE", 99.9); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-99.9) > 1e-6 {
|
||||
t.Errorf("updated Double = %g, want ~99.9", tv.Double)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for updated monitor value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeStopsUpdates(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
// Drain initial value.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
|
||||
unsub() // unsubscribe
|
||||
|
||||
// Give the EVENT_CANCEL message time to be delivered.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Push a value; channel should NOT receive it.
|
||||
srv.SetValue("TEST:DOUBLE", 777.0)
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
// It's possible one update slipped through before cancel was processed.
|
||||
// Accept it, but not a second one.
|
||||
t.Logf("received one update after unsub (value=%g), checking for second...", tv.Double)
|
||||
select {
|
||||
case <-ch:
|
||||
t.Error("received second update after unsubscribe")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Good — no second update.
|
||||
}
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// No update received — correct.
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Put tests //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPutDouble(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Subscribe to observe the effect of Put.
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial value.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
|
||||
if err := cli.Put(ctx, "TEST:DOUBLE", float64(2.718)); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-2.718) > 1e-6 {
|
||||
t.Errorf("post-put Double = %g, want ~2.718", tv.Double)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for post-put monitor value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLong(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:LONG", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
<-ch // drain initial
|
||||
|
||||
if err := cli.Put(ctx, "TEST:LONG", int(100)); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if tv.Long != 100 {
|
||||
t.Errorf("post-put Long = %d, want 100", tv.Long)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for post-put value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutString(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:STRING", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
<-ch // drain initial
|
||||
|
||||
if err := cli.Put(ctx, "TEST:STRING", "world"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if tv.Str != "world" {
|
||||
t.Errorf("post-put Str = %q, want %q", tv.Str, "world")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for post-put value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutReadOnly(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := cli.Put(ctx, "TEST:READONLY", float64(1.0))
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to read-only PV")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Multiple subscribers on the same PV //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestMultipleSubscribers(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const n = 3
|
||||
chs := make([]chan proto.TimeValue, n)
|
||||
for i := range chs {
|
||||
chs[i] = make(chan proto.TimeValue, 8)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", chs[i])
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe[%d]: %v", i, err)
|
||||
}
|
||||
defer unsub()
|
||||
}
|
||||
|
||||
// Drain initial values.
|
||||
for i, ch := range chs {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timeout waiting for initial value on subscriber %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Push a new value.
|
||||
srv.SetValue("TEST:DOUBLE", 55.5)
|
||||
|
||||
for i, ch := range chs {
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-55.5) > 1e-6 {
|
||||
t.Errorf("subscriber %d: Double = %g, want 55.5", i, tv.Double)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timeout waiting for update on subscriber %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Context cancellation //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetContextCancelled(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled
|
||||
|
||||
_, err := cli.Get(ctx, "TEST:DOUBLE")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with cancelled context")
|
||||
}
|
||||
}
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
writeQueueDepth = 256
|
||||
echoInterval = 15 * time.Second
|
||||
connectTimeout = 5 * time.Second
|
||||
maxReconnDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// reply — async GET callback payload //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// reply is delivered to a pending GET waiter via a buffered channel.
|
||||
// If ok is false the circuit died before the reply arrived.
|
||||
type reply struct {
|
||||
hdr proto.Header
|
||||
payload []byte
|
||||
ok bool
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// monState — one active EVENT_ADD subscription //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// monState survives reconnections; the circuit re-sends EVENT_ADD with the
|
||||
// new SID each time a CREATE_CHAN reply is received.
|
||||
type monState struct {
|
||||
subID uint32
|
||||
dbrType uint16
|
||||
count uint32
|
||||
ch chan<- proto.TimeValue // caller-owned, never closed here
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// chanState — one CA channel (persists across reconnections) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// chanState field invariants:
|
||||
// - cid, pvName: immutable after construction.
|
||||
// - sid, dbfType, count, access: valid only while readyC is closed.
|
||||
// - readyC: closed when CREATE_CHAN reply received; replaced on reconnect.
|
||||
// - monitors: append-only (except removeMonitor); never cleared on reconnect.
|
||||
type chanState struct {
|
||||
cid uint32
|
||||
pvName string
|
||||
|
||||
mu sync.RWMutex
|
||||
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
|
||||
dbfType int // native DBF field type
|
||||
count uint32 // element count
|
||||
access uint32 // AccessRead | AccessWrite bitmask
|
||||
gotChan bool // CREATE_CHAN reply received for current connection
|
||||
gotAccess bool // ACCESS_RIGHTS received for current connection
|
||||
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
|
||||
monitors []*monState // active subscriptions
|
||||
}
|
||||
|
||||
func newChanState(cid uint32, pvName string) *chanState {
|
||||
return &chanState{
|
||||
cid: cid,
|
||||
pvName: pvName,
|
||||
readyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// resetForReconnect prepares cs for a new TCP connection.
|
||||
// It closes the current readyC (waking any waitReady callers so they can
|
||||
// re-wait on the fresh channel) and then replaces it.
|
||||
// Must be called before the circuit sends CREATE_CHAN on the new conn.
|
||||
// In-flight GET requests are tracked in circuit.byIOID and cleared there.
|
||||
func (cs *chanState) resetForReconnect() {
|
||||
cs.mu.Lock()
|
||||
cs.sid = 0
|
||||
cs.gotChan = false
|
||||
cs.gotAccess = false
|
||||
old := cs.readyC
|
||||
cs.readyC = make(chan struct{})
|
||||
cs.mu.Unlock()
|
||||
// Close the old readyC outside the lock to avoid deadlock with waitReady.
|
||||
select {
|
||||
case <-old:
|
||||
// Already closed (CmdCreateFail path or double-reset guard).
|
||||
default:
|
||||
close(old)
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady blocks until both CREATE_CHAN and ACCESS_RIGHTS have been received
|
||||
// (i.e. sid != 0 and gotAccess == true) or ctx expires. It loops through
|
||||
// reconnections automatically: when resetForReconnect closes the current readyC
|
||||
// the goroutine wakes, sees sid == 0, and waits on the freshly created channel.
|
||||
func (cs *chanState) waitReady(ctx context.Context) error {
|
||||
for {
|
||||
cs.mu.RLock()
|
||||
sid := cs.sid
|
||||
gotAccess := cs.gotAccess
|
||||
ready := cs.readyC
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if sid != 0 && gotAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ready:
|
||||
// State changed — loop to re-check sid and gotAccess.
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// circuit — persistent TCP connection to one CA server //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// circuit manages a persistent, auto-reconnecting TCP connection to a single
|
||||
// CA server. All channels and monitors created on a circuit survive
|
||||
// reconnections; the circuit re-creates them transparently.
|
||||
//
|
||||
// Goroutine model:
|
||||
// - run() goroutine: reconnect loop; drives the write loop for each conn.
|
||||
// - readLoop() goroutine: one per active conn; dispatches inbound messages.
|
||||
//
|
||||
// Lock ordering: circuit.mu → chanState.mu (never the reverse).
|
||||
type circuit struct {
|
||||
addr string
|
||||
clientName string
|
||||
hostName string
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.RWMutex
|
||||
channels []*chanState // append-only; survive reconnect
|
||||
byCID map[uint32]*chanState // cid → chanState (fast lookup)
|
||||
bySID map[uint32]*chanState // sid → chanState (fast lookup)
|
||||
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
|
||||
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
|
||||
|
||||
writeQ chan []byte // serialised outbound message queue
|
||||
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
|
||||
}
|
||||
|
||||
func newCircuit(ctx context.Context, addr, clientName, hostName string) *circuit {
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
c := &circuit{
|
||||
addr: addr,
|
||||
clientName: clientName,
|
||||
hostName: hostName,
|
||||
ctx: cctx,
|
||||
cancel: cancel,
|
||||
byCID: make(map[uint32]*chanState),
|
||||
bySID: make(map[uint32]*chanState),
|
||||
bySubID: make(map[uint32]*monState),
|
||||
byIOID: make(map[uint32]chan reply),
|
||||
writeQ: make(chan []byte, writeQueueDepth),
|
||||
}
|
||||
go c.run()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *circuit) close() { c.cancel() }
|
||||
|
||||
func (c *circuit) nextID() uint32 { return c.seq.Add(1) }
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Reconnect loop //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (c *circuit) run() {
|
||||
delay := time.Second
|
||||
for {
|
||||
if c.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dbg("CA circuit connecting", "addr", c.addr)
|
||||
conn, err := c.dial()
|
||||
if err != nil {
|
||||
dbg("CA circuit dial failed", "addr", c.addr, "err", err)
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
delay = min(delay*2, maxReconnDelay)
|
||||
continue
|
||||
}
|
||||
dbg("CA circuit connected", "addr", c.addr)
|
||||
delay = time.Second // reset back-off on successful connect
|
||||
|
||||
// Snapshot channels and reset their per-connection state.
|
||||
c.mu.Lock()
|
||||
c.bySID = make(map[uint32]*chanState)
|
||||
// Close all in-flight GET reply channels so waiters unblock with ok=false.
|
||||
for _, ch := range c.byIOID {
|
||||
close(ch)
|
||||
}
|
||||
c.byIOID = make(map[uint32]chan reply)
|
||||
for _, cs := range c.channels {
|
||||
cs.resetForReconnect()
|
||||
}
|
||||
chs := make([]*chanState, len(c.channels))
|
||||
copy(chs, c.channels)
|
||||
c.mu.Unlock()
|
||||
|
||||
// Drain stale messages from the write queue (they carry old SIDs).
|
||||
for len(c.writeQ) > 0 {
|
||||
<-c.writeQ
|
||||
}
|
||||
|
||||
// Send VERSION + HOST_NAME + CLIENT_NAME.
|
||||
if err := c.sendHandshake(conn); err != nil {
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-create all known channels on the new connection.
|
||||
setupOK := true
|
||||
for _, cs := range chs {
|
||||
if _, err := conn.Write(buildCreateChanMsg(cs.cid, cs.pvName)); err != nil {
|
||||
setupOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !setupOK {
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
dbg("CA handshake sent", "addr", c.addr, "channels", len(chs))
|
||||
|
||||
// Start read loop.
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
c.readLoop(conn)
|
||||
dbg("CA read loop exited", "addr", c.addr)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Write loop (run goroutine acts as the write pump).
|
||||
c.writeLoop(conn, readDone)
|
||||
|
||||
conn.Close()
|
||||
<-readDone // drain read goroutine before reconnecting
|
||||
|
||||
if c.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
delay = min(delay*2, maxReconnDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *circuit) writeLoop(conn net.Conn, readDone <-chan struct{}) {
|
||||
ticker := time.NewTicker(echoInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.writeQ:
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Periodic heartbeat; server echoes it back.
|
||||
hb := proto.BuildMessage(proto.Header{Command: proto.CmdEcho}, nil)
|
||||
if _, err := conn.Write(hb); err != nil {
|
||||
return
|
||||
}
|
||||
case <-readDone:
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Dial + handshake //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (c *circuit) dial() (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: connectTimeout}
|
||||
conn, err := d.DialContext(c.ctx, "tcp4", c.addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: dial %s: %w", c.addr, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *circuit) sendHandshake(conn net.Conn) error {
|
||||
ver := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdVersion,
|
||||
DataCount: proto.MinorVersion,
|
||||
}, nil)
|
||||
host := proto.BuildMessage(
|
||||
proto.Header{Command: proto.CmdHostName},
|
||||
proto.BuildStringPayload(c.hostName),
|
||||
)
|
||||
client := proto.BuildMessage(
|
||||
proto.Header{Command: proto.CmdClientName},
|
||||
proto.BuildStringPayload(c.clientName),
|
||||
)
|
||||
out := make([]byte, 0, len(ver)+len(host)+len(client))
|
||||
out = append(out, ver...)
|
||||
out = append(out, host...)
|
||||
out = append(out, client...)
|
||||
_, err := conn.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Read loop + message dispatch //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (c *circuit) readLoop(conn net.Conn) {
|
||||
for {
|
||||
hdr, _, err := proto.DecodeHeader(conn)
|
||||
if err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
dbg("CA read loop error (header)", "addr", c.addr, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
dbg("CA recv", "addr", c.addr, "cmd", hdr.Command, "dataType", hdr.DataType,
|
||||
"dataCount", hdr.DataCount, "payloadSize", hdr.PayloadSize,
|
||||
"p1", hdr.Parameter1, "p2", hdr.Parameter2)
|
||||
var payload []byte
|
||||
if hdr.PayloadSize > 0 {
|
||||
payload = make([]byte, hdr.PayloadSize)
|
||||
if _, err = io.ReadFull(conn, payload); err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
dbg("CA read loop error (payload)", "addr", c.addr,
|
||||
"cmd", hdr.Command, "wantBytes", hdr.PayloadSize, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
c.dispatch(conn, hdr, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch handles one inbound CA message.
|
||||
func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
||||
switch hdr.Command {
|
||||
|
||||
case proto.CmdVersion:
|
||||
// Server version negotiation reply — nothing to do.
|
||||
|
||||
case proto.CmdCreateChan:
|
||||
// Parameter1 = cid (echoed), Parameter2 = SID assigned by server.
|
||||
c.mu.Lock()
|
||||
cs, ok := c.byCID[hdr.Parameter1]
|
||||
if ok {
|
||||
c.bySID[hdr.Parameter2] = cs
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cs.mu.Lock()
|
||||
cs.sid = hdr.Parameter2
|
||||
cs.dbfType = int(hdr.DataType)
|
||||
cs.count = hdr.DataCount
|
||||
cs.gotChan = true
|
||||
var ready chan struct{}
|
||||
if cs.gotAccess {
|
||||
ready = cs.readyC
|
||||
}
|
||||
// Snapshot monitors for EVENT_ADD re-subscription.
|
||||
mons := make([]*monState, len(cs.monitors))
|
||||
copy(mons, cs.monitors)
|
||||
cs.mu.Unlock()
|
||||
|
||||
dbg("CA CREATE_CHAN reply", "pv", cs.pvName, "sid", hdr.Parameter2,
|
||||
"dbfType", hdr.DataType, "count", hdr.DataCount, "monitors", len(mons))
|
||||
|
||||
// Re-subscribe all monitors with the new SID.
|
||||
for _, ms := range mons {
|
||||
msg := buildEventAddMsg(ms.subID, hdr.Parameter2, ms.dbrType, ms.count)
|
||||
dbg("CA EVENT_ADD sent", "pv", cs.pvName, "subID", ms.subID,
|
||||
"sid", hdr.Parameter2, "dbrType", ms.dbrType, "count", ms.count)
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
// Unblock waitReady only once ACCESS_RIGHTS has also arrived.
|
||||
// ACCESS_RIGHTS always follows CREATE_CHAN, so ready is typically nil here
|
||||
// and the close happens in the CmdAccessRights handler below.
|
||||
if ready != nil {
|
||||
close(ready)
|
||||
}
|
||||
|
||||
case proto.CmdCreateFail:
|
||||
// Server does not host this PV (or quota exceeded).
|
||||
c.mu.RLock()
|
||||
cs, ok := c.byCID[hdr.Parameter1]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dbg("CA CREATE_FAIL", "pv", cs.pvName, "cid", hdr.Parameter1)
|
||||
cs.mu.RLock()
|
||||
ready := cs.readyC
|
||||
cs.mu.RUnlock()
|
||||
// readyC is only closed once per connection cycle by this goroutine,
|
||||
// so the select here is safe (no concurrent close).
|
||||
select {
|
||||
case <-ready:
|
||||
// Already closed (shouldn't happen, but guard against it).
|
||||
default:
|
||||
close(ready)
|
||||
}
|
||||
|
||||
case proto.CmdAccessRights:
|
||||
// Parameter1 = cid, Parameter2 = access bitmask.
|
||||
// ACCESS_RIGHTS always arrives just after CREATE_CHAN; we defer closing
|
||||
// readyC until here so callers see the correct access value immediately.
|
||||
c.mu.RLock()
|
||||
cs, ok := c.byCID[hdr.Parameter1]
|
||||
c.mu.RUnlock()
|
||||
dbg("CA ACCESS_RIGHTS", "cid", hdr.Parameter1, "access", hdr.Parameter2, "found", ok)
|
||||
if ok {
|
||||
cs.mu.Lock()
|
||||
cs.access = hdr.Parameter2
|
||||
cs.gotAccess = true
|
||||
var ready chan struct{}
|
||||
if cs.gotChan {
|
||||
ready = cs.readyC
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
if ready != nil {
|
||||
select {
|
||||
case <-ready:
|
||||
// Already closed (guard against duplicate ACCESS_RIGHTS).
|
||||
default:
|
||||
close(ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case proto.CmdEventAdd:
|
||||
// Monitor update: server puts subscription ID in Parameter2 (m_available).
|
||||
// Parameter1 carries ECA_NORMAL (1) as a status code.
|
||||
subID := hdr.Parameter2
|
||||
c.mu.RLock()
|
||||
ms, ok := c.bySubID[subID]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
dbg("CA EVENT_ADD for unknown subID", "subID", subID)
|
||||
return
|
||||
}
|
||||
tv, ok := proto.DecodeTimeValue(hdr.DataType, hdr.DataCount, payload)
|
||||
if !ok {
|
||||
dbg("CA EVENT_ADD decode failed", "subID", subID,
|
||||
"dbrType", hdr.DataType, "count", hdr.DataCount, "payloadLen", len(payload))
|
||||
return
|
||||
}
|
||||
dbg("CA EVENT_ADD value", "subID", subID, "dbrType", hdr.DataType,
|
||||
"double", tv.Double, "severity", tv.Severity)
|
||||
select {
|
||||
case ms.ch <- tv:
|
||||
default: // drop if consumer is slow
|
||||
}
|
||||
|
||||
case proto.CmdReadNotify:
|
||||
// Parameter2 = ioid (our request ID echoed back by server).
|
||||
ioid := hdr.Parameter2
|
||||
|
||||
c.mu.Lock()
|
||||
replyCh, ok := c.byIOID[ioid]
|
||||
if ok {
|
||||
delete(c.byIOID, ioid)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
dbg("CA CmdReadNotify", "ioid", ioid, "found", ok, "dbrType", hdr.DataType,
|
||||
"count", hdr.DataCount, "payloadSize", hdr.PayloadSize)
|
||||
|
||||
if ok {
|
||||
select {
|
||||
case replyCh <- reply{hdr: hdr, payload: payload, ok: true}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
case proto.CmdEcho:
|
||||
// Server heartbeat — no response needed (client sends its own echoes).
|
||||
|
||||
case proto.CmdServerDisc:
|
||||
// Server is shutting down — close conn to trigger reconnect.
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Message builders //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func buildCreateChanMsg(cid uint32, pvName string) []byte {
|
||||
return proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdCreateChan,
|
||||
Parameter1: cid,
|
||||
Parameter2: proto.MinorVersion,
|
||||
}, proto.BuildStringPayload(pvName))
|
||||
}
|
||||
|
||||
func buildEventAddMsg(subID, sid uint32, dbrType uint16, count uint32) []byte {
|
||||
return proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdEventAdd,
|
||||
DataType: dbrType,
|
||||
DataCount: count,
|
||||
Parameter1: sid, // m_cid = channel SID (per CA spec)
|
||||
Parameter2: subID, // m_available = client subscription ID
|
||||
}, proto.EncodeEventMask(proto.DBEDefault))
|
||||
}
|
||||
|
||||
func buildEventCancelMsg(subID, sid uint32, dbrType uint16) []byte {
|
||||
return proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdEventCancel,
|
||||
DataType: dbrType,
|
||||
Parameter1: sid, // m_cid = channel SID
|
||||
Parameter2: subID, // m_available = client subscription ID
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Channel and monitor management //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// getOrCreateChannel returns the chanState for pvName, creating it if needed.
|
||||
// If the circuit is currently connected, CREATE_CHAN is queued immediately;
|
||||
// otherwise the reconnect loop will send it when it next connects.
|
||||
func (c *circuit) getOrCreateChannel(pvName string) *chanState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, cs := range c.channels {
|
||||
if cs.pvName == pvName {
|
||||
return cs
|
||||
}
|
||||
}
|
||||
cid := c.nextID()
|
||||
cs := newChanState(cid, pvName)
|
||||
c.channels = append(c.channels, cs)
|
||||
c.byCID[cid] = cs
|
||||
// Best-effort: deliver CREATE_CHAN if circuit is up.
|
||||
msg := buildCreateChanMsg(cid, pvName)
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
default:
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
// addMonitor registers ms on cs and queues EVENT_ADD if the channel is ready.
|
||||
// If the channel is not yet ready the CREATE_CHAN reply handler will send
|
||||
// EVENT_ADD when the connection is established.
|
||||
func (c *circuit) addMonitor(cs *chanState, ms *monState) {
|
||||
c.mu.Lock()
|
||||
c.bySubID[ms.subID] = ms
|
||||
c.mu.Unlock()
|
||||
|
||||
cs.mu.Lock()
|
||||
cs.monitors = append(cs.monitors, ms)
|
||||
sid := cs.sid
|
||||
cs.mu.Unlock()
|
||||
|
||||
if sid != 0 {
|
||||
dbg("CA EVENT_ADD sent (addMonitor)", "pv", cs.pvName, "subID", ms.subID,
|
||||
"sid", sid, "dbrType", ms.dbrType, "count", ms.count)
|
||||
msg := buildEventAddMsg(ms.subID, sid, ms.dbrType, ms.count)
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeMonitor unregisters a monitor and queues EVENT_CANCEL if connected.
|
||||
func (c *circuit) removeMonitor(cs *chanState, subID uint32) {
|
||||
c.mu.Lock()
|
||||
delete(c.bySubID, subID)
|
||||
c.mu.Unlock()
|
||||
|
||||
cs.mu.Lock()
|
||||
var dbrType uint16
|
||||
remaining := cs.monitors[:0:0] // fresh backing array
|
||||
for _, ms := range cs.monitors {
|
||||
if ms.subID == subID {
|
||||
dbrType = ms.dbrType
|
||||
} else {
|
||||
remaining = append(remaining, ms)
|
||||
}
|
||||
}
|
||||
cs.monitors = remaining
|
||||
sid := cs.sid
|
||||
cs.mu.Unlock()
|
||||
|
||||
if sid != 0 {
|
||||
msg := buildEventCancelMsg(subID, sid, dbrType)
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Channel operations: get and put //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// getRaw performs a READ_NOTIFY and returns the undecoded header + payload.
|
||||
// Use this when you need to decode a type that get() does not handle
|
||||
// (e.g. DBR_CTRL_* for metadata retrieval).
|
||||
func (c *circuit) getRaw(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.Header, []byte, error) {
|
||||
if err := cs.waitReady(ctx); err != nil {
|
||||
return proto.Header{}, nil, fmt.Errorf("ca: %q: %w", cs.pvName, err)
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
sid := cs.sid
|
||||
cs.mu.RUnlock()
|
||||
if sid == 0 {
|
||||
return proto.Header{}, nil, fmt.Errorf("ca: %q: channel not connected", cs.pvName)
|
||||
}
|
||||
|
||||
ioid := c.nextID()
|
||||
replyCh := make(chan reply, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
c.byIOID[ioid] = replyCh
|
||||
c.mu.Unlock()
|
||||
|
||||
msg := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdReadNotify,
|
||||
DataType: dbrType,
|
||||
DataCount: count,
|
||||
Parameter1: sid, // m_cid = channel SID (per CA spec)
|
||||
Parameter2: ioid, // m_available = request IOId
|
||||
}, nil)
|
||||
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.byIOID, ioid)
|
||||
c.mu.Unlock()
|
||||
return proto.Header{}, nil, ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-replyCh:
|
||||
if !r.ok {
|
||||
return proto.Header{}, nil, fmt.Errorf("ca: %q: circuit disconnected", cs.pvName)
|
||||
}
|
||||
return r.hdr, r.payload, nil
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.byIOID, ioid)
|
||||
c.mu.Unlock()
|
||||
return proto.Header{}, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// get performs a READ_NOTIFY (async GET) and returns the decoded value.
|
||||
// It blocks until the reply arrives, the channel is not yet ready, or ctx expires.
|
||||
func (c *circuit) get(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.TimeValue, error) {
|
||||
if err := cs.waitReady(ctx); err != nil {
|
||||
return proto.TimeValue{}, fmt.Errorf("ca: %q: %w", cs.pvName, err)
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
sid := cs.sid
|
||||
cs.mu.RUnlock()
|
||||
if sid == 0 {
|
||||
return proto.TimeValue{}, fmt.Errorf("ca: %q: channel not connected", cs.pvName)
|
||||
}
|
||||
|
||||
ioid := c.nextID()
|
||||
replyCh := make(chan reply, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
c.byIOID[ioid] = replyCh
|
||||
c.mu.Unlock()
|
||||
|
||||
msg := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdReadNotify,
|
||||
DataType: dbrType,
|
||||
DataCount: count,
|
||||
Parameter1: sid, // m_cid = channel SID (per CA spec)
|
||||
Parameter2: ioid, // m_available = request IOId
|
||||
}, nil)
|
||||
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.byIOID, ioid)
|
||||
c.mu.Unlock()
|
||||
return proto.TimeValue{}, ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-replyCh:
|
||||
if !r.ok {
|
||||
return proto.TimeValue{}, fmt.Errorf("ca: %q: circuit disconnected during GET", cs.pvName)
|
||||
}
|
||||
tv, ok := proto.DecodeTimeValue(r.hdr.DataType, r.hdr.DataCount, r.payload)
|
||||
if !ok {
|
||||
return proto.TimeValue{}, fmt.Errorf("ca: %q: failed to decode GET reply", cs.pvName)
|
||||
}
|
||||
return tv, nil
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.byIOID, ioid)
|
||||
c.mu.Unlock()
|
||||
return proto.TimeValue{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// put queues a CA_PROTO_WRITE (fire-and-forget put).
|
||||
// It waits for the channel to be ready and access rights to be confirmed.
|
||||
func (c *circuit) put(ctx context.Context, cs *chanState, dbrType uint16, payload []byte) error {
|
||||
if err := cs.waitReady(ctx); err != nil {
|
||||
return fmt.Errorf("ca: %q: %w", cs.pvName, err)
|
||||
}
|
||||
|
||||
cs.mu.RLock()
|
||||
sid := cs.sid
|
||||
access := cs.access
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if sid == 0 {
|
||||
return fmt.Errorf("ca: %q: channel not connected", cs.pvName)
|
||||
}
|
||||
if access&proto.AccessWrite == 0 {
|
||||
return fmt.Errorf("ca: %q: read-only", cs.pvName)
|
||||
}
|
||||
|
||||
// CA_PROTO_WRITE: DataType=dbrType, DataCount=1, Parameter1=SID, Parameter2=0 (no ioid).
|
||||
msg := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdWrite,
|
||||
DataType: dbrType,
|
||||
DataCount: 1,
|
||||
Parameter1: sid,
|
||||
Parameter2: 0,
|
||||
}, payload)
|
||||
|
||||
select {
|
||||
case c.writeQ <- msg:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// calog is the package-level debug logger.
|
||||
// It is active only when the environment variable UOPI_CA_DEBUG is set to a
|
||||
// non-empty value at program startup. All output goes to stderr.
|
||||
var calog *slog.Logger
|
||||
|
||||
func init() {
|
||||
if os.Getenv("UOPI_CA_DEBUG") != "" {
|
||||
calog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// dbg emits a DEBUG log if UOPI_CA_DEBUG is set. It is a no-op otherwise.
|
||||
func dbg(msg string, args ...any) {
|
||||
if calog != nil {
|
||||
calog.Debug(msg, args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package ca implements an EPICS Channel Access (CA) client in pure Go.
|
||||
//
|
||||
// It requires no CGo, no libca, and no EPICS installation on the build machine.
|
||||
// A single [Client] manages connections to one or more IOCs, multiplexing
|
||||
// channels over shared TCP virtual circuits that survive IOC restarts.
|
||||
//
|
||||
// # Quick start
|
||||
//
|
||||
// // Configure from standard EPICS environment variables.
|
||||
// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv())
|
||||
// if err != nil { ... }
|
||||
// defer cli.Close()
|
||||
//
|
||||
// // Subscribe to live monitor updates.
|
||||
// ch := make(chan proto.TimeValue, 16)
|
||||
// cancel, err := cli.Subscribe(ctx, "MY:PV", ch)
|
||||
// if err != nil { ... }
|
||||
// defer cancel()
|
||||
// for tv := range ch {
|
||||
// fmt.Println(tv.Timestamp, tv.Double)
|
||||
// }
|
||||
//
|
||||
// // One-shot read (current value).
|
||||
// tv, err := cli.Get(ctx, "MY:PV")
|
||||
//
|
||||
// // Retrieve full control-block metadata (units, display limits, enum strings).
|
||||
// ci, err := cli.GetCtrl(ctx, "MY:PV")
|
||||
// if ci.Double != nil {
|
||||
// fmt.Println(ci.Double.Units, ci.Double.UpperDispLimit)
|
||||
// }
|
||||
//
|
||||
// // Write a new value (fire-and-forget).
|
||||
// err = cli.Put(ctx, "MY:PV", 42.0)
|
||||
//
|
||||
// # Configuration
|
||||
//
|
||||
// [ConfigFromEnv] reads the standard EPICS CA environment variables:
|
||||
//
|
||||
// EPICS_CA_ADDR_LIST space-separated list of server addresses
|
||||
// EPICS_CA_AUTO_ADDR_LIST set to "NO" to disable local broadcast
|
||||
//
|
||||
// Alternatively, construct [Config] directly and pass to [NewClient].
|
||||
//
|
||||
// # Protocol
|
||||
//
|
||||
// The library speaks Channel Access protocol version 4.13 over UDP (search,
|
||||
// port 5064) and TCP (data, port 5064). It does not use the CA Repeater;
|
||||
// it sends search requests directly to the addresses in [Config.AddrList].
|
||||
//
|
||||
// UDP search uses exponential back-off (100 ms → 30 s) and retries until the
|
||||
// PV is found or the context is cancelled.
|
||||
//
|
||||
// # Reconnection
|
||||
//
|
||||
// When an IOC restarts, the library detects the broken TCP connection and
|
||||
// reconnects automatically. All active subscriptions are re-established
|
||||
// transparently after the new CREATE_CHAN handshake completes.
|
||||
// Callers blocked in [Client.Get] or [Client.Put] during a reconnect will
|
||||
// receive an error and should retry.
|
||||
//
|
||||
// # Thread safety
|
||||
//
|
||||
// All exported methods are safe for concurrent use from multiple goroutines.
|
||||
// A single [Client] can be shared across the entire application.
|
||||
package ca
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/uopi/goca
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,444 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// epicsEpochOffset is the number of Unix seconds between the Unix epoch
|
||||
// (1970-01-01 00:00:00 UTC) and the EPICS epoch (1990-01-01 00:00:00 UTC).
|
||||
const epicsEpochOffset = 631152000
|
||||
|
||||
// maxStringSize is the fixed size of a CA string value (MAX_STRING_SIZE).
|
||||
const maxStringSize = 40
|
||||
|
||||
// maxEnumStates is the maximum number of enum strings (MAX_ENUM_STATES).
|
||||
const maxEnumStates = 16
|
||||
|
||||
// maxEnumStringSize is the fixed size of each enum string (MAX_ENUM_STRING_SIZE).
|
||||
const maxEnumStringSize = 26
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Alarm types //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// AlarmSeverity mirrors epicsAlarmSeverity.
|
||||
type AlarmSeverity int
|
||||
|
||||
const (
|
||||
SeverityNone AlarmSeverity = 0
|
||||
SeverityMinor AlarmSeverity = 1
|
||||
SeverityMajor AlarmSeverity = 2
|
||||
SeverityInvalid AlarmSeverity = 3
|
||||
)
|
||||
|
||||
func (s AlarmSeverity) String() string {
|
||||
switch s {
|
||||
case SeverityNone:
|
||||
return "NO_ALARM"
|
||||
case SeverityMinor:
|
||||
return "MINOR"
|
||||
case SeverityMajor:
|
||||
return "MAJOR"
|
||||
case SeverityInvalid:
|
||||
return "INVALID"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// TimeValue — decoded DBR_TIME_* payload //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// TimeValue is the decoded form of any DBR_TIME_* payload.
|
||||
// Exactly one of the value fields is populated based on the DBR type.
|
||||
type TimeValue struct {
|
||||
Timestamp time.Time
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
// Value fields — only one is set.
|
||||
Double float64
|
||||
Float float32
|
||||
Long int32
|
||||
Short int16
|
||||
Enum uint16
|
||||
Char uint8
|
||||
Str string
|
||||
// Waveform values (when element count > 1).
|
||||
Doubles []float64
|
||||
}
|
||||
|
||||
// decodeTimestamp converts EPICS secPastEpoch+nsec to a Go time.Time.
|
||||
func decodeTimestamp(secPastEpoch, nsec uint32) time.Time {
|
||||
return time.Unix(int64(secPastEpoch)+epicsEpochOffset, int64(nsec)).UTC()
|
||||
}
|
||||
|
||||
// caString extracts a null-terminated string from a fixed-size byte slice.
|
||||
func caString(b []byte) string {
|
||||
idx := strings.IndexByte(string(b), 0)
|
||||
if idx < 0 {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:idx])
|
||||
}
|
||||
|
||||
// DBR_TIME_* wire layouts (all big-endian, matching EPICS db_access.h structs):
|
||||
//
|
||||
// Common header (12 bytes):
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:8] uint32 secPastEpoch
|
||||
// [8:12] uint32 nsec
|
||||
//
|
||||
// DBR_TIME_STRING (type 14): [40]byte at [12:52]. Total=52.
|
||||
// DBR_TIME_SHORT (type 15): 2-byte RISC pad at [12:14], int16 at [14:16]. Total=16.
|
||||
// DBR_TIME_FLOAT (type 16): float32 at [12:16]. Total=16.
|
||||
// DBR_TIME_ENUM (type 17): 2-byte RISC pad at [12:14], uint16 at [14:16]. Total=16.
|
||||
// DBR_TIME_CHAR (type 18): RISC_pad0[12:14], RISC_pad1[14], uint8 at [15]. Total=16.
|
||||
// DBR_TIME_LONG (type 19): int32 at [12:16]. Total=16.
|
||||
// DBR_TIME_DOUBLE (type 20): 4-byte RISC pad at [12:16], float64 at [16:24]. Total=24.
|
||||
|
||||
// DecodeTimeValue decodes a DBR_TIME_* payload.
|
||||
// dbrType is one of the DBRTime* constants; payload is the full message payload
|
||||
// (may include multiple elements for waveforms; count is the element count).
|
||||
func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, bool) {
|
||||
if len(payload) < 12 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
|
||||
status := int16(binary.BigEndian.Uint16(payload[0:]))
|
||||
severity := AlarmSeverity(binary.BigEndian.Uint16(payload[2:]))
|
||||
sec := binary.BigEndian.Uint32(payload[4:])
|
||||
nsec := binary.BigEndian.Uint32(payload[8:])
|
||||
|
||||
tv := TimeValue{
|
||||
Timestamp: decodeTimestamp(sec, nsec),
|
||||
Status: status,
|
||||
Severity: severity,
|
||||
}
|
||||
|
||||
switch dbrType {
|
||||
case DBRTimeDouble:
|
||||
// 4-byte RISC pad at [12:16], value at [16:24]
|
||||
if count > 1 {
|
||||
if len(payload) < 16+int(count)*8 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
vals := make([]float64, count)
|
||||
for i := range vals {
|
||||
bits := binary.BigEndian.Uint64(payload[16+i*8:])
|
||||
vals[i] = math.Float64frombits(bits)
|
||||
}
|
||||
tv.Doubles = vals
|
||||
tv.Double = vals[0]
|
||||
} else {
|
||||
if len(payload) < 24 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
bits := binary.BigEndian.Uint64(payload[16:])
|
||||
tv.Double = math.Float64frombits(bits)
|
||||
}
|
||||
|
||||
case DBRTimeFloat:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
bits := binary.BigEndian.Uint32(payload[12:])
|
||||
tv.Float = math.Float32frombits(bits)
|
||||
tv.Double = float64(tv.Float)
|
||||
|
||||
case DBRTimeLong:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Long = int32(binary.BigEndian.Uint32(payload[12:]))
|
||||
tv.Double = float64(tv.Long)
|
||||
|
||||
case DBRTimeShort:
|
||||
// 2-byte RISC pad at [12:14], value at [14:16]
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Short = int16(binary.BigEndian.Uint16(payload[14:]))
|
||||
tv.Double = float64(tv.Short)
|
||||
|
||||
case DBRTimeEnum:
|
||||
// 2-byte RISC pad at [12:14], value at [14:16]
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Enum = binary.BigEndian.Uint16(payload[14:])
|
||||
tv.Double = float64(tv.Enum)
|
||||
|
||||
case DBRTimeChar:
|
||||
// RISC_pad0[12:14], RISC_pad1[14], value[15] per db_access.h dbr_time_char
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Char = payload[15]
|
||||
tv.Double = float64(tv.Char)
|
||||
|
||||
case DBRTimeString:
|
||||
if len(payload) < 12+maxStringSize {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Str = caString(payload[12 : 12+maxStringSize])
|
||||
tv.Double = 0
|
||||
|
||||
default:
|
||||
return TimeValue{}, false
|
||||
}
|
||||
|
||||
return tv, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlDouble — decoded DBR_CTRL_DOUBLE payload (type 34) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (88 bytes total, all big-endian):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:6] int16 precision
|
||||
// [6:8] uint16 RISC_pad0
|
||||
// [8:16] [8]byte units
|
||||
// [16:24] float64 upper_disp_limit
|
||||
// [24:32] float64 lower_disp_limit
|
||||
// [32:40] float64 upper_alarm_limit
|
||||
// [40:48] float64 upper_warning_limit
|
||||
// [48:56] float64 lower_warning_limit
|
||||
// [56:64] float64 lower_alarm_limit
|
||||
// [64:72] float64 upper_ctrl_limit
|
||||
// [72:80] float64 lower_ctrl_limit
|
||||
// [80:88] float64 value
|
||||
|
||||
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
|
||||
type CtrlDouble struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Precision int16
|
||||
Units string
|
||||
UpperDispLimit float64
|
||||
LowerDispLimit float64
|
||||
UpperAlarmLimit float64
|
||||
UpperWarnLimit float64
|
||||
LowerWarnLimit float64
|
||||
LowerAlarmLimit float64
|
||||
UpperCtrlLimit float64
|
||||
LowerCtrlLimit float64
|
||||
Value float64
|
||||
}
|
||||
|
||||
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
|
||||
func DecodeCtrlDouble(p []byte) (CtrlDouble, bool) {
|
||||
if len(p) < 88 {
|
||||
return CtrlDouble{}, false
|
||||
}
|
||||
f64 := func(off int) float64 {
|
||||
return math.Float64frombits(binary.BigEndian.Uint64(p[off:]))
|
||||
}
|
||||
return CtrlDouble{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Precision: int16(binary.BigEndian.Uint16(p[4:])),
|
||||
Units: caString(p[8:16]),
|
||||
UpperDispLimit: f64(16),
|
||||
LowerDispLimit: f64(24),
|
||||
UpperAlarmLimit: f64(32),
|
||||
UpperWarnLimit: f64(40),
|
||||
LowerWarnLimit: f64(48),
|
||||
LowerAlarmLimit: f64(56),
|
||||
UpperCtrlLimit: f64(64),
|
||||
LowerCtrlLimit: f64(72),
|
||||
Value: f64(80),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlLong — decoded DBR_CTRL_LONG payload (type 33) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (48 bytes total):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:12] [8]byte units
|
||||
// [12:16] int32 upper_disp_limit
|
||||
// [16:20] int32 lower_disp_limit
|
||||
// [20:24] int32 upper_alarm_limit
|
||||
// [24:28] int32 upper_warning_limit
|
||||
// [28:32] int32 lower_warning_limit
|
||||
// [32:36] int32 lower_alarm_limit
|
||||
// [36:40] int32 upper_ctrl_limit
|
||||
// [40:44] int32 lower_ctrl_limit
|
||||
// [44:48] int32 value
|
||||
|
||||
// CtrlLong is the decoded form of a DBR_CTRL_LONG payload.
|
||||
type CtrlLong struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Units string
|
||||
UpperDispLimit int32
|
||||
LowerDispLimit int32
|
||||
UpperAlarmLimit int32
|
||||
UpperWarnLimit int32
|
||||
LowerWarnLimit int32
|
||||
LowerAlarmLimit int32
|
||||
UpperCtrlLimit int32
|
||||
LowerCtrlLimit int32
|
||||
Value int32
|
||||
}
|
||||
|
||||
// DecodeCtrlLong decodes a DBR_CTRL_LONG payload (minimum 48 bytes).
|
||||
func DecodeCtrlLong(p []byte) (CtrlLong, bool) {
|
||||
if len(p) < 48 {
|
||||
return CtrlLong{}, false
|
||||
}
|
||||
i32 := func(off int) int32 {
|
||||
return int32(binary.BigEndian.Uint32(p[off:]))
|
||||
}
|
||||
return CtrlLong{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Units: caString(p[4:12]),
|
||||
UpperDispLimit: i32(12),
|
||||
LowerDispLimit: i32(16),
|
||||
UpperAlarmLimit: i32(20),
|
||||
UpperWarnLimit: i32(24),
|
||||
LowerWarnLimit: i32(28),
|
||||
LowerAlarmLimit: i32(32),
|
||||
UpperCtrlLimit: i32(36),
|
||||
LowerCtrlLimit: i32(40),
|
||||
Value: i32(44),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlEnum — decoded DBR_CTRL_ENUM payload (type 31) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (424 bytes total):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:6] int16 no_str (number of valid enum strings, max 16)
|
||||
// [6:422] [16][26]byte strs (enum string table)
|
||||
// [422:424] uint16 value
|
||||
|
||||
// CtrlEnum is the decoded form of a DBR_CTRL_ENUM payload.
|
||||
type CtrlEnum struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Strings []string // len == no_str
|
||||
Value uint16
|
||||
}
|
||||
|
||||
const ctrlEnumSize = 2 + 2 + 2 + maxEnumStates*maxEnumStringSize + 2 // 424
|
||||
|
||||
// DecodeCtrlEnum decodes a DBR_CTRL_ENUM payload (minimum 424 bytes).
|
||||
func DecodeCtrlEnum(p []byte) (CtrlEnum, bool) {
|
||||
if len(p) < ctrlEnumSize {
|
||||
return CtrlEnum{}, false
|
||||
}
|
||||
noStr := int(binary.BigEndian.Uint16(p[4:]))
|
||||
noStr = min(noStr, maxEnumStates)
|
||||
strs := make([]string, noStr)
|
||||
for i := range strs {
|
||||
off := 6 + i*maxEnumStringSize
|
||||
strs[i] = caString(p[off : off+maxEnumStringSize])
|
||||
}
|
||||
return CtrlEnum{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Strings: strs,
|
||||
Value: binary.BigEndian.Uint16(p[422:]),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlString — decoded DBR_CTRL_STRING payload (type 28) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (44 bytes):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:44] [40]byte value
|
||||
|
||||
// CtrlString is the decoded form of a DBR_CTRL_STRING payload.
|
||||
type CtrlString struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Value string
|
||||
}
|
||||
|
||||
// DecodeCtrlString decodes a DBR_CTRL_STRING payload (minimum 44 bytes).
|
||||
func DecodeCtrlString(p []byte) (CtrlString, bool) {
|
||||
if len(p) < 4+maxStringSize {
|
||||
return CtrlString{}, false
|
||||
}
|
||||
return CtrlString{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Value: caString(p[4 : 4+maxStringSize]),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Put payload encoders //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// EncodeDouble encodes a float64 value as a big-endian DBR_DOUBLE payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeDouble(v float64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, math.Float64bits(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeLong encodes an int32 value as a big-endian DBR_LONG payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeLong(v int32) []byte {
|
||||
b := make([]byte, 8) // 4 bytes value + 4 bytes pad
|
||||
binary.BigEndian.PutUint32(b, uint32(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeShort encodes an int16 value as a big-endian DBR_SHORT payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeShort(v int16) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(b, uint16(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeString encodes a string as a null-terminated, 8-byte padded
|
||||
// DBR_STRING payload (capped at maxStringSize characters).
|
||||
func EncodeString(v string) []byte {
|
||||
if len(v) >= maxStringSize {
|
||||
v = v[:maxStringSize-1]
|
||||
}
|
||||
b := make([]byte, maxStringSize)
|
||||
copy(b, v)
|
||||
return PadBytes(b)
|
||||
}
|
||||
|
||||
// EncodeEventMask builds the 16-byte payload for a CA_PROTO_EVENT_ADD request.
|
||||
// mask is typically DBEDefault (DBEValue | DBEAlarm).
|
||||
//
|
||||
// Wire layout matches caProto.h struct mon_info (all big-endian):
|
||||
//
|
||||
// [0:4] float32 m_lval (low delta, zero = disabled)
|
||||
// [4:8] float32 m_hval (high delta, zero = disabled)
|
||||
// [8:12] float32 m_toval (period between samples, zero = disabled)
|
||||
// [12:14] uint16 m_mask (DBE_VALUE | DBE_ALARM | ...)
|
||||
// [14:16] uint16 m_pad (alignment padding)
|
||||
func EncodeEventMask(mask uint16) []byte {
|
||||
b := make([]byte, 16)
|
||||
binary.BigEndian.PutUint16(b[12:], mask) // m_mask is at offset 12 per caProto.h
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Header is the decoded form of a 16-byte CA message header.
|
||||
// All integer fields are stored in host byte order after decoding.
|
||||
type Header struct {
|
||||
Command uint16
|
||||
PayloadSize uint32 // actual payload size (resolved from extended form if needed)
|
||||
DataType uint16
|
||||
DataCount uint32 // actual element count (resolved from extended form if needed)
|
||||
Parameter1 uint32
|
||||
Parameter2 uint32
|
||||
}
|
||||
|
||||
// HeaderSize is the wire size of a standard CA header.
|
||||
const HeaderSize = 16
|
||||
|
||||
// Encode writes the header to buf (must be at least HeaderSize bytes).
|
||||
// If PayloadSize > 0xFFFE or DataCount > 0xFFFF the extended encoding is used
|
||||
// and the caller must prepend an extra 8 bytes; use EncodeExtended instead.
|
||||
func (h Header) Encode(buf []byte) {
|
||||
binary.BigEndian.PutUint16(buf[0:], h.Command)
|
||||
binary.BigEndian.PutUint16(buf[2:], uint16(h.PayloadSize))
|
||||
binary.BigEndian.PutUint16(buf[4:], h.DataType)
|
||||
binary.BigEndian.PutUint16(buf[6:], uint16(h.DataCount))
|
||||
binary.BigEndian.PutUint32(buf[8:], h.Parameter1)
|
||||
binary.BigEndian.PutUint32(buf[12:], h.Parameter2)
|
||||
}
|
||||
|
||||
// Bytes returns the 16-byte wire encoding of h.
|
||||
func (h Header) Bytes() []byte {
|
||||
buf := make([]byte, HeaderSize)
|
||||
h.Encode(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeHeader reads exactly one CA header from r, handling the extended
|
||||
// encoding (payload_size == 0xFFFF) transparently.
|
||||
// Returns the decoded Header and the number of bytes read (16 or 24).
|
||||
func DecodeHeader(r io.Reader) (Header, int, error) {
|
||||
var raw [HeaderSize]byte
|
||||
if _, err := io.ReadFull(r, raw[:]); err != nil {
|
||||
return Header{}, 0, fmt.Errorf("ca: read header: %w", err)
|
||||
}
|
||||
|
||||
h := Header{
|
||||
Command: binary.BigEndian.Uint16(raw[0:]),
|
||||
DataType: binary.BigEndian.Uint16(raw[4:]),
|
||||
Parameter1: binary.BigEndian.Uint32(raw[8:]),
|
||||
Parameter2: binary.BigEndian.Uint32(raw[12:]),
|
||||
}
|
||||
|
||||
rawPayload := binary.BigEndian.Uint16(raw[2:])
|
||||
rawCount := binary.BigEndian.Uint16(raw[6:])
|
||||
|
||||
// Extended message: payload_size == 0xFFFF means real sizes are in the
|
||||
// next 8 bytes (parameter1 = real payload size, parameter2 = real count).
|
||||
if rawPayload == 0xFFFF && rawCount == 0x0000 {
|
||||
var ext [8]byte
|
||||
if _, err := io.ReadFull(r, ext[:]); err != nil {
|
||||
return Header{}, 16, fmt.Errorf("ca: read extended header: %w", err)
|
||||
}
|
||||
h.PayloadSize = binary.BigEndian.Uint32(ext[0:])
|
||||
h.DataCount = binary.BigEndian.Uint32(ext[4:])
|
||||
// Note: parameter1/2 in the base header are overwritten by the extended values.
|
||||
// In practice, the original parameter1/2 are still valid — the extended header
|
||||
// only carries sizes, not the original parameters. We already decoded parameter1/2
|
||||
// above from the base header.
|
||||
return h, 24, nil
|
||||
}
|
||||
|
||||
h.PayloadSize = uint32(rawPayload)
|
||||
h.DataCount = uint32(rawCount)
|
||||
return h, 16, nil
|
||||
}
|
||||
|
||||
// PadTo8 returns n rounded up to the nearest multiple of 8.
|
||||
// CA requires all message payloads to be padded to 8-byte boundaries.
|
||||
func PadTo8(n int) int {
|
||||
return (n + 7) &^ 7
|
||||
}
|
||||
|
||||
// PadBytes appends zero bytes to b until len(b) is a multiple of 8.
|
||||
func PadBytes(b []byte) []byte {
|
||||
need := PadTo8(len(b)) - len(b)
|
||||
return append(b, make([]byte, need)...)
|
||||
}
|
||||
|
||||
// BuildMessage assembles a complete CA message (header + payload).
|
||||
// payload may be nil for zero-length messages.
|
||||
func BuildMessage(h Header, payload []byte) []byte {
|
||||
h.PayloadSize = uint32(len(payload))
|
||||
msg := make([]byte, HeaderSize+len(payload))
|
||||
h.Encode(msg)
|
||||
copy(msg[HeaderSize:], payload)
|
||||
return msg
|
||||
}
|
||||
|
||||
// BuildStringPayload encodes a string as a null-terminated, 8-byte padded payload.
|
||||
func BuildStringPayload(s string) []byte {
|
||||
b := append([]byte(s), 0) // null terminator
|
||||
return PadBytes(b)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package proto contains the low-level Channel Access wire-protocol constants,
|
||||
// header codec, and DBR type decode functions. All types are big-endian as
|
||||
// required by the CA specification.
|
||||
package proto
|
||||
|
||||
// CA command opcodes (uint16, first field of every message header).
|
||||
const (
|
||||
CmdVersion = 0 // version negotiation (first message on every TCP connection)
|
||||
CmdEventAdd = 1 // subscribe to value updates / server event push
|
||||
CmdEventCancel = 2 // cancel subscription
|
||||
CmdWrite = 4 // put value (fire-and-forget, no server reply)
|
||||
CmdSearch = 6 // UDP: locate IOC hosting a PV
|
||||
CmdNotFound = 14 // UDP: IOC does not host the requested PV
|
||||
CmdReadNotify = 15 // get value with callback (async GET)
|
||||
CmdCreateChan = 18 // create channel on TCP circuit
|
||||
CmdWriteNotify = 19 // put value with acknowledgement
|
||||
CmdClientName = 20 // announce client username (sent after VERSION)
|
||||
CmdHostName = 21 // announce client hostname (sent after VERSION)
|
||||
CmdAccessRights = 22 // server → client: access rights bitmask
|
||||
CmdEcho = 23 // heartbeat ping/pong
|
||||
CmdCreateFail = 26 // server → client: CREATE_CHAN failed
|
||||
CmdServerDisc = 27 // server → client: server shutting down
|
||||
)
|
||||
|
||||
// CA minor protocol revision announced during the VERSION handshake.
|
||||
// Corresponds to CA 4.13, supported by EPICS 3.14+ and all EPICS 7 servers.
|
||||
const MinorVersion = 13
|
||||
|
||||
// Default CA port (TCP and UDP).
|
||||
const DefaultPort = 5064
|
||||
|
||||
// Search reply data_type values.
|
||||
const (
|
||||
SearchReply = 10 // DO_REPLY: ask server to respond
|
||||
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
|
||||
)
|
||||
|
||||
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
|
||||
const (
|
||||
AccessRead = 1 << 0
|
||||
AccessWrite = 1 << 1
|
||||
)
|
||||
|
||||
// DBE event mask bits used in the 16-byte EVENT_ADD payload.
|
||||
const (
|
||||
DBEValue = 0x01 // trigger on any value change
|
||||
DBEAlarm = 0x04 // trigger on alarm state change
|
||||
DBEDefault = DBEValue | DBEAlarm
|
||||
)
|
||||
|
||||
// ---- DBF field types (native IOC field type, reported in CREATE_CHAN reply) ----
|
||||
|
||||
const (
|
||||
DBFString = 0
|
||||
DBFShort = 1 // also DBFInt
|
||||
DBFFloat = 2
|
||||
DBFEnum = 3
|
||||
DBFChar = 4
|
||||
DBFLong = 5
|
||||
DBFDouble = 6
|
||||
)
|
||||
|
||||
// ---- DBR request types ----
|
||||
|
||||
// Plain value types (used in WRITE).
|
||||
const (
|
||||
DBRString = 0
|
||||
DBRShort = 1
|
||||
DBRFloat = 2
|
||||
DBREnum = 3
|
||||
DBRChar = 4
|
||||
DBRLong = 5
|
||||
DBRDouble = 6
|
||||
)
|
||||
|
||||
// DBR_TIME_* types (value + timestamp + alarm status; used in EVENT_ADD).
|
||||
// Numbers from db_access.h: STRING=14, SHORT/INT=15, FLOAT=16, ENUM=17, CHAR=18, LONG=19, DOUBLE=20.
|
||||
const (
|
||||
DBRTimeString = 14
|
||||
DBRTimeShort = 15
|
||||
DBRTimeFloat = 16
|
||||
DBRTimeEnum = 17
|
||||
DBRTimeChar = 18
|
||||
DBRTimeLong = 19
|
||||
DBRTimeDouble = 20
|
||||
)
|
||||
|
||||
// DBR_CTRL_* types (full control info: units, limits, enum strings; used in READ_NOTIFY).
|
||||
const (
|
||||
DBRCtrlString = 28
|
||||
DBRCtrlShort = 29
|
||||
DBRCtrlFloat = 30
|
||||
DBRCtrlEnum = 31
|
||||
DBRCtrlChar = 32
|
||||
DBRCtrlLong = 33
|
||||
DBRCtrlDouble = 34
|
||||
)
|
||||
|
||||
// NativeTimeType maps a DBF field type to the corresponding DBR_TIME_* type
|
||||
// that should be used for monitor subscriptions.
|
||||
func NativeTimeType(dbfType int, elementCount int) uint16 {
|
||||
switch dbfType {
|
||||
case DBFDouble:
|
||||
return DBRTimeDouble
|
||||
case DBFFloat:
|
||||
return DBRTimeFloat
|
||||
case DBFLong:
|
||||
return DBRTimeLong
|
||||
case DBFShort:
|
||||
return DBRTimeShort
|
||||
case DBFEnum:
|
||||
return DBRTimeEnum
|
||||
case DBFString:
|
||||
return DBRTimeString
|
||||
case DBFChar:
|
||||
if elementCount > 1 {
|
||||
return DBRTimeString // waveform of chars treated as string
|
||||
}
|
||||
return DBRTimeChar
|
||||
default:
|
||||
return DBRTimeDouble
|
||||
}
|
||||
}
|
||||
|
||||
// NativeCtrlType maps a DBF field type to the corresponding DBR_CTRL_* type
|
||||
// that should be used for metadata gets.
|
||||
func NativeCtrlType(dbfType int) uint16 {
|
||||
switch dbfType {
|
||||
case DBFDouble, DBFFloat:
|
||||
return DBRCtrlDouble
|
||||
case DBFLong, DBFShort, DBFChar:
|
||||
return DBRCtrlLong
|
||||
case DBFEnum:
|
||||
return DBRCtrlEnum
|
||||
case DBFString:
|
||||
return DBRCtrlString
|
||||
default:
|
||||
return DBRCtrlDouble
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package proto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Header round-trip //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestHeaderRoundTrip(t *testing.T) {
|
||||
h := proto.Header{
|
||||
Command: proto.CmdCreateChan,
|
||||
PayloadSize: 16,
|
||||
DataType: proto.DBFDouble,
|
||||
DataCount: 1,
|
||||
Parameter1: 0xDEAD,
|
||||
Parameter2: 0xBEEF,
|
||||
}
|
||||
wire := h.Bytes()
|
||||
if len(wire) != proto.HeaderSize {
|
||||
t.Fatalf("Bytes() len = %d, want %d", len(wire), proto.HeaderSize)
|
||||
}
|
||||
|
||||
got, n, err := proto.DecodeHeader(bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHeader: %v", err)
|
||||
}
|
||||
if n != proto.HeaderSize {
|
||||
t.Errorf("bytes consumed = %d, want %d", n, proto.HeaderSize)
|
||||
}
|
||||
if got.Command != h.Command {
|
||||
t.Errorf("Command = %d, want %d", got.Command, h.Command)
|
||||
}
|
||||
if got.DataType != h.DataType {
|
||||
t.Errorf("DataType = %d, want %d", got.DataType, h.DataType)
|
||||
}
|
||||
if got.Parameter1 != h.Parameter1 {
|
||||
t.Errorf("Parameter1 = %d, want %d", got.Parameter1, h.Parameter1)
|
||||
}
|
||||
if got.Parameter2 != h.Parameter2 {
|
||||
t.Errorf("Parameter2 = %d, want %d", got.Parameter2, h.Parameter2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
payload := []byte("hello\x00\x00\x00") // 8 bytes (padded)
|
||||
h := proto.Header{Command: proto.CmdHostName, PayloadSize: 0}
|
||||
msg := proto.BuildMessage(h, payload)
|
||||
if len(msg) != proto.HeaderSize+len(payload) {
|
||||
t.Fatalf("len(msg) = %d, want %d", len(msg), proto.HeaderSize+len(payload))
|
||||
}
|
||||
// PayloadSize in wire should equal len(payload).
|
||||
wireSize := binary.BigEndian.Uint16(msg[2:4])
|
||||
if int(wireSize) != len(payload) {
|
||||
t.Errorf("wire PayloadSize = %d, want %d", wireSize, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadTo8(t *testing.T) {
|
||||
cases := [][2]int{{0, 0}, {1, 8}, {7, 8}, {8, 8}, {9, 16}, {16, 16}, {17, 24}}
|
||||
for _, c := range cases {
|
||||
if got := proto.PadTo8(c[0]); got != c[1] {
|
||||
t.Errorf("PadTo8(%d) = %d, want %d", c[0], got, c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStringPayload(t *testing.T) {
|
||||
p := proto.BuildStringPayload("TEST")
|
||||
if len(p)%8 != 0 {
|
||||
t.Errorf("payload length %d not padded to 8", len(p))
|
||||
}
|
||||
if p[4] != 0 {
|
||||
t.Errorf("expected null terminator at index 4, got %d", p[4])
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_TIME_* decoding //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// buildTimeHeader builds the 12-byte common DBR_TIME header.
|
||||
func buildTimeHeader(status, severity int16, sec, nsec uint32) []byte {
|
||||
b := make([]byte, 12)
|
||||
binary.BigEndian.PutUint16(b[0:], uint16(status))
|
||||
binary.BigEndian.PutUint16(b[2:], uint16(severity))
|
||||
binary.BigEndian.PutUint32(b[4:], sec)
|
||||
binary.BigEndian.PutUint32(b[8:], nsec)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDecodeTimeDouble(t *testing.T) {
|
||||
const sec = 1_000_000
|
||||
const nsec = 500_000_000
|
||||
const want = 3.14159
|
||||
|
||||
hdr := buildTimeHeader(0, 0, sec, nsec)
|
||||
pad := make([]byte, 4) // RISC pad
|
||||
val := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(val, math.Float64bits(want))
|
||||
payload := append(append(hdr, pad...), val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if math.Abs(tv.Double-want) > 1e-10 {
|
||||
t.Errorf("Double = %g, want %g", tv.Double, want)
|
||||
}
|
||||
// EPICS epoch offset: 1990-01-01 00:00:00 UTC = Unix 631152000.
|
||||
wantUnix := int64(sec) + 631152000
|
||||
if tv.Timestamp.Unix() != wantUnix {
|
||||
t.Errorf("Timestamp.Unix() = %d, want %d", tv.Timestamp.Unix(), wantUnix)
|
||||
}
|
||||
if tv.Timestamp.Nanosecond() != int(nsec) {
|
||||
t.Errorf("Timestamp.Nanosecond() = %d, want %d", tv.Timestamp.Nanosecond(), nsec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeLong(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 1, 0, 0)
|
||||
val := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(val, 0xFFFFFFD6) // -42 as two's complement
|
||||
payload := append(hdr, val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeLong, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Long != -42 {
|
||||
t.Errorf("Long = %d, want -42", tv.Long)
|
||||
}
|
||||
if tv.Severity != proto.SeverityMinor {
|
||||
t.Errorf("Severity = %v, want Minor", tv.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeString(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
str := make([]byte, 40)
|
||||
copy(str, "hello")
|
||||
payload := append(hdr, str...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeString, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Str != "hello" {
|
||||
t.Errorf("Str = %q, want %q", tv.Str, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeEnum(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := []byte{0x00, 0x00, 0x00, 0x03} // 2-byte RISC pad + enum=3
|
||||
payload := append(hdr, val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeEnum, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Enum != 3 {
|
||||
t.Errorf("Enum = %d, want 3", tv.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeWaveform(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
pad := make([]byte, 4)
|
||||
vals := []float64{1.0, 2.5, -0.5}
|
||||
valbytes := make([]byte, len(vals)*8)
|
||||
for i, v := range vals {
|
||||
binary.BigEndian.PutUint64(valbytes[i*8:], math.Float64bits(v))
|
||||
}
|
||||
payload := append(append(hdr, pad...), valbytes...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 3, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if len(tv.Doubles) != 3 {
|
||||
t.Fatalf("len(Doubles) = %d, want 3", len(tv.Doubles))
|
||||
}
|
||||
for i, want := range vals {
|
||||
if math.Abs(tv.Doubles[i]-want) > 1e-12 {
|
||||
t.Errorf("Doubles[%d] = %g, want %g", i, tv.Doubles[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeTooShort(t *testing.T) {
|
||||
_, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, []byte{0, 1, 2})
|
||||
if ok {
|
||||
t.Error("expected false for short payload")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_CTRL_DOUBLE decoding //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDecodeCtrlDouble(t *testing.T) {
|
||||
p := make([]byte, 88)
|
||||
// status=0, severity=0, precision=3, pad=0, units="mA\0..."
|
||||
binary.BigEndian.PutUint16(p[4:], 3) // precision
|
||||
copy(p[8:], "mA")
|
||||
f64 := func(off int, v float64) {
|
||||
binary.BigEndian.PutUint64(p[off:], math.Float64bits(v))
|
||||
}
|
||||
f64(16, 100.0) // upper_disp
|
||||
f64(24, 0.0) // lower_disp
|
||||
f64(32, 90.0) // upper_alarm
|
||||
f64(40, 80.0) // upper_warn
|
||||
f64(48, 20.0) // lower_warn
|
||||
f64(56, 10.0) // lower_alarm
|
||||
f64(64, 95.0) // upper_ctrl
|
||||
f64(72, 5.0) // lower_ctrl
|
||||
f64(80, 55.5) // value
|
||||
|
||||
cd, ok := proto.DecodeCtrlDouble(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlDouble returned false")
|
||||
}
|
||||
if cd.Units != "mA" {
|
||||
t.Errorf("Units = %q, want %q", cd.Units, "mA")
|
||||
}
|
||||
if cd.Precision != 3 {
|
||||
t.Errorf("Precision = %d, want 3", cd.Precision)
|
||||
}
|
||||
if math.Abs(cd.Value-55.5) > 1e-10 {
|
||||
t.Errorf("Value = %g, want 55.5", cd.Value)
|
||||
}
|
||||
if math.Abs(cd.UpperDispLimit-100.0) > 1e-10 {
|
||||
t.Errorf("UpperDispLimit = %g, want 100.0", cd.UpperDispLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCtrlEnum(t *testing.T) {
|
||||
p := make([]byte, 424)
|
||||
binary.BigEndian.PutUint16(p[4:], 3) // no_str = 3
|
||||
strs := []string{"OFF", "ON", "FAULT"}
|
||||
for i, s := range strs {
|
||||
copy(p[6+i*26:], s)
|
||||
}
|
||||
binary.BigEndian.PutUint16(p[422:], 1) // value = 1
|
||||
|
||||
ce, ok := proto.DecodeCtrlEnum(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlEnum returned false")
|
||||
}
|
||||
if len(ce.Strings) != 3 {
|
||||
t.Fatalf("len(Strings) = %d, want 3", len(ce.Strings))
|
||||
}
|
||||
if ce.Strings[2] != "FAULT" {
|
||||
t.Errorf("Strings[2] = %q, want FAULT", ce.Strings[2])
|
||||
}
|
||||
if ce.Value != 1 {
|
||||
t.Errorf("Value = %d, want 1", ce.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Put payload encoders //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestEncodeDouble(t *testing.T) {
|
||||
b := proto.EncodeDouble(3.14)
|
||||
if len(b) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(b))
|
||||
}
|
||||
got := math.Float64frombits(binary.BigEndian.Uint64(b))
|
||||
if math.Abs(got-3.14) > 1e-15 {
|
||||
t.Errorf("decoded = %g, want 3.14", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeLong(t *testing.T) {
|
||||
b := proto.EncodeLong(-1)
|
||||
if len(b) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(b))
|
||||
}
|
||||
got := int32(binary.BigEndian.Uint32(b))
|
||||
if got != -1 {
|
||||
t.Errorf("decoded = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeString(t *testing.T) {
|
||||
b := proto.EncodeString("hello")
|
||||
if len(b)%8 != 0 {
|
||||
t.Errorf("len %d not padded to 8", len(b))
|
||||
}
|
||||
if string(b[:5]) != "hello" {
|
||||
t.Errorf("content = %q, want %q", b[:5], "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeEventMask(t *testing.T) {
|
||||
b := proto.EncodeEventMask(proto.DBEDefault)
|
||||
if len(b) != 16 {
|
||||
t.Fatalf("len = %d, want 16", len(b))
|
||||
}
|
||||
// m_mask is at offset 12 per caProto.h struct mon_info.
|
||||
mask := binary.BigEndian.Uint16(b[12:])
|
||||
if mask != proto.DBEDefault {
|
||||
t.Errorf("mask = 0x%02X, want 0x%02X", mask, proto.DBEDefault)
|
||||
}
|
||||
// m_pad at [14:16] must be zero.
|
||||
if pad := binary.BigEndian.Uint16(b[14:]); pad != 0 {
|
||||
t.Errorf("m_pad = %d, want 0", pad)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// NativeTimeType / NativeCtrlType //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestNativeTimeType(t *testing.T) {
|
||||
cases := []struct {
|
||||
dbf int
|
||||
n int
|
||||
want uint16
|
||||
}{
|
||||
{proto.DBFDouble, 1, proto.DBRTimeDouble},
|
||||
{proto.DBFFloat, 1, proto.DBRTimeFloat},
|
||||
{proto.DBFLong, 1, proto.DBRTimeLong},
|
||||
{proto.DBFShort, 1, proto.DBRTimeShort},
|
||||
{proto.DBFEnum, 1, proto.DBRTimeEnum},
|
||||
{proto.DBFString, 1, proto.DBRTimeString},
|
||||
{proto.DBFChar, 1, proto.DBRTimeChar},
|
||||
{proto.DBFChar, 10, proto.DBRTimeString}, // char waveform → string
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := proto.NativeTimeType(c.dbf, c.n)
|
||||
if got != c.want {
|
||||
t.Errorf("NativeTimeType(%d,%d) = %d, want %d", c.dbf, c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// AlarmSeverity.String //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestAlarmSeverityString(t *testing.T) {
|
||||
cases := []struct {
|
||||
s proto.AlarmSeverity
|
||||
want string
|
||||
}{
|
||||
{proto.SeverityNone, "NO_ALARM"},
|
||||
{proto.SeverityMinor, "MINOR"},
|
||||
{proto.SeverityMajor, "MAJOR"},
|
||||
{proto.SeverityInvalid, "INVALID"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.s.String(); got != c.want {
|
||||
t.Errorf("Severity(%d).String() = %q, want %q", c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Timestamp sanity check //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestEPICSEpoch(t *testing.T) {
|
||||
// secPastEpoch=0 should decode to 1990-01-01 00:00:00 UTC.
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := make([]byte, 8)
|
||||
payload := append(append(hdr, make([]byte, 4)...), val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
want := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !tv.Timestamp.Equal(want) {
|
||||
t.Errorf("timestamp = %v, want %v", tv.Timestamp, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// searchResult is returned to a waiter when a search succeeds.
|
||||
type searchResult struct {
|
||||
addr string // "ip:port" TCP address of the IOC
|
||||
}
|
||||
|
||||
// searchWaiter tracks an outstanding PV search request.
|
||||
type searchWaiter struct {
|
||||
pvName string
|
||||
ch chan searchResult // closed or sent to exactly once
|
||||
}
|
||||
|
||||
// searchEngine performs UDP-based PV name → IOC address resolution.
|
||||
// It sends CA_PROTO_SEARCH datagrams to all configured server addresses and
|
||||
// listens for replies, matching them to pending waiters via a searchID.
|
||||
type searchEngine struct {
|
||||
addrs []string // "ip:port" strings to search
|
||||
|
||||
mu sync.Mutex
|
||||
waiters map[uint32]*searchWaiter // searchID → waiter
|
||||
|
||||
idSeq atomic.Uint32
|
||||
conn *net.UDPConn // shared UDP socket (nil if not started)
|
||||
}
|
||||
|
||||
func newSearchEngine(addrs []string) *searchEngine {
|
||||
return &searchEngine{
|
||||
addrs: addrs,
|
||||
waiters: make(map[uint32]*searchWaiter),
|
||||
}
|
||||
}
|
||||
|
||||
// start opens the shared UDP socket and launches the receive goroutine.
|
||||
// ctx cancellation stops the receive loop and closes the socket.
|
||||
func (se *searchEngine) start(ctx context.Context) error {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca search: open UDP socket: %w", err)
|
||||
}
|
||||
se.conn = conn
|
||||
|
||||
go se.recvLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookup resolves pvName to an IOC TCP address.
|
||||
// It retries with exponential back-off until ctx is cancelled or done.
|
||||
func (se *searchEngine) lookup(ctx context.Context, pvName string) (string, error) {
|
||||
id := se.idSeq.Add(1)
|
||||
w := &searchWaiter{
|
||||
pvName: pvName,
|
||||
ch: make(chan searchResult, 1),
|
||||
}
|
||||
|
||||
se.mu.Lock()
|
||||
se.waiters[id] = w
|
||||
se.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
se.mu.Lock()
|
||||
delete(se.waiters, id)
|
||||
se.mu.Unlock()
|
||||
}()
|
||||
|
||||
delay := 100 * time.Millisecond
|
||||
const maxDelay = 30 * time.Second
|
||||
|
||||
for {
|
||||
if err := se.sendSearch(id, pvName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-w.ch:
|
||||
return res.addr, nil
|
||||
case <-time.After(delay):
|
||||
delay *= 2
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("ca search %q: %w", pvName, ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendSearch sends a CA_PROTO_VERSION + CA_PROTO_SEARCH burst to all addresses.
|
||||
// The CA protocol requires both messages to be in the same UDP datagram.
|
||||
func (se *searchEngine) sendSearch(id uint32, pvName string) error {
|
||||
payload := proto.BuildStringPayload(pvName)
|
||||
|
||||
// VERSION message (no payload).
|
||||
verHdr := proto.Header{
|
||||
Command: proto.CmdVersion,
|
||||
DataCount: proto.MinorVersion,
|
||||
}
|
||||
|
||||
// SEARCH message.
|
||||
searchHdr := proto.Header{
|
||||
Command: proto.CmdSearch,
|
||||
DataType: proto.SearchReply,
|
||||
DataCount: proto.MinorVersion,
|
||||
Parameter1: id,
|
||||
Parameter2: id,
|
||||
}
|
||||
searchMsg := proto.BuildMessage(searchHdr, payload)
|
||||
|
||||
// Bundle into a single datagram — softIoc (and the CA Repeater) require
|
||||
// VERSION and SEARCH to arrive together in one UDP packet.
|
||||
pkt := make([]byte, 0, proto.HeaderSize+len(searchMsg))
|
||||
pkt = append(pkt, verHdr.Bytes()...)
|
||||
pkt = append(pkt, searchMsg...)
|
||||
|
||||
for _, addr := range se.addrs {
|
||||
udpAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = se.conn.WriteTo(pkt, udpAddr)
|
||||
dbg("CA search sent", "pv", pvName, "id", id, "dst", udpAddr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recvLoop reads UDP datagrams and dispatches search replies.
|
||||
func (se *searchEngine) recvLoop(ctx context.Context) {
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
// Use a short read deadline so we can check ctx.Done() periodically.
|
||||
_ = se.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, srcAddr, err := se.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
dbg("CA UDP datagram received", "src", srcAddr, "bytes", n)
|
||||
se.parseReply(buf[:n], srcAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// parseReply scans a UDP datagram for CA_PROTO_SEARCH response messages and
|
||||
// wakes the matching waiter.
|
||||
func (se *searchEngine) parseReply(data []byte, src *net.UDPAddr) {
|
||||
for len(data) >= proto.HeaderSize {
|
||||
h, n, err := proto.DecodeHeader(newBytesReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payloadEnd := n + int(h.PayloadSize)
|
||||
if payloadEnd > len(data) {
|
||||
return
|
||||
}
|
||||
payload := data[n:payloadEnd]
|
||||
data = data[payloadEnd:]
|
||||
|
||||
if h.Command != proto.CmdSearch {
|
||||
continue
|
||||
}
|
||||
|
||||
// data_type field = TCP port of the CA server.
|
||||
tcpPort := int(h.DataType)
|
||||
searchID := h.Parameter2
|
||||
|
||||
// Resolve server IP.
|
||||
//
|
||||
// Two payload formats exist in the wild:
|
||||
//
|
||||
// New (≥ CA 4.9): [IP(4)][pad(4)]
|
||||
// Old (< CA 4.9): [minor_version(2)][pad(2)][IP(4)]
|
||||
//
|
||||
// The old format is identified by payload[0] == 0x00 (the high byte of a
|
||||
// uint16 minor version is always zero for CA versions ≤ 255, whereas a
|
||||
// real IPv4 address never starts with 0x00).
|
||||
//
|
||||
// In both formats, 0xFFFFFFFF in the IP position means "use sender IP".
|
||||
var serverIP net.IP
|
||||
if len(payload) >= 8 && payload[0] == 0x00 {
|
||||
// Old format: minor version at [0:2], IP at [4:8].
|
||||
ipSlice := payload[4:8]
|
||||
if useSenderIP(ipSlice) {
|
||||
serverIP = src.IP
|
||||
} else {
|
||||
ip := make(net.IP, 4)
|
||||
copy(ip, ipSlice)
|
||||
serverIP = ip
|
||||
}
|
||||
} else if len(payload) >= 4 {
|
||||
// New format: IP at [0:4].
|
||||
if useSenderIP(payload[:4]) {
|
||||
serverIP = src.IP
|
||||
} else {
|
||||
ip := make(net.IP, 4)
|
||||
copy(ip, payload[:4])
|
||||
serverIP = ip
|
||||
}
|
||||
} else {
|
||||
serverIP = src.IP
|
||||
}
|
||||
|
||||
tcpAddr := fmt.Sprintf("%s:%d", serverIP.String(), tcpPort)
|
||||
dbg("CA search reply", "searchID", searchID, "tcpAddr", tcpAddr, "src", src)
|
||||
|
||||
se.mu.Lock()
|
||||
w, ok := se.waiters[searchID]
|
||||
if ok {
|
||||
delete(se.waiters, searchID)
|
||||
}
|
||||
se.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
dbg("CA search matched", "pv", w.pvName, "ioc", tcpAddr)
|
||||
select {
|
||||
case w.ch <- searchResult{addr: tcpAddr}:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
dbg("CA search reply unmatched", "searchID", searchID, "known_waiters", func() int {
|
||||
se.mu.Lock()
|
||||
defer se.mu.Unlock()
|
||||
return len(se.waiters)
|
||||
}())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// useSenderIP reports whether an IP field in a CA search reply means
|
||||
// "use the sender's address". Both 0.0.0.0 (all-zeros) and 255.255.255.255
|
||||
// (all-ones / 0xFFFFFFFF) are used by different EPICS Base versions.
|
||||
func useSenderIP(b []byte) bool {
|
||||
allFF := true
|
||||
allZero := true
|
||||
for _, v := range b {
|
||||
if v != 0xFF {
|
||||
allFF = false
|
||||
}
|
||||
if v != 0x00 {
|
||||
allZero = false
|
||||
}
|
||||
}
|
||||
return allFF || allZero
|
||||
}
|
||||
|
||||
func isAllFF(b []byte) bool {
|
||||
for _, v := range b {
|
||||
if v != 0xFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Address list helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// resolveAddrs converts a list of "host" or "host:port" strings to
|
||||
// "host:port" strings using defaultPort when no port is specified.
|
||||
func resolveAddrs(addrs []string, defaultPort int) []string {
|
||||
out := make([]string, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
_, _, err := net.SplitHostPort(a)
|
||||
if err != nil {
|
||||
// No port specified; append default.
|
||||
a = fmt.Sprintf("%s:%d", a, defaultPort)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localBroadcastAddrs returns broadcast addresses for all local network
|
||||
// interfaces, used when AutoAddrList is true.
|
||||
func localBroadcastAddrs(port int) []string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagBroadcast == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipNet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip4 := ipNet.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
// Compute broadcast: IP | ^mask
|
||||
mask := ipNet.Mask
|
||||
bcast := make(net.IP, 4)
|
||||
for i := range bcast {
|
||||
bcast[i] = ip4[i] | ^mask[i]
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s:%d", bcast.String(), port))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Minimal bytes.Reader-like for proto.DecodeHeader //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
type bytesReader struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
|
||||
|
||||
func (r *bytesReader) Read(p []byte) (int, error) {
|
||||
if r.i >= len(r.b) {
|
||||
return 0, fmt.Errorf("EOF")
|
||||
}
|
||||
n := copy(p, r.b[r.i:])
|
||||
r.i += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// searchAddrFromReply extracts server IP from a CA_PROTO_SEARCH response. //
|
||||
// Exported for testability. //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// EncodedSearchPair builds the VERSION + SEARCH UDP datagram pair for pvName
|
||||
// with the given searchID. Exported for use in tests.
|
||||
func EncodedSearchPair(pvName string, searchID uint32) []byte {
|
||||
verHdr := proto.Header{
|
||||
Command: proto.CmdVersion,
|
||||
DataCount: proto.MinorVersion,
|
||||
}
|
||||
payload := proto.BuildStringPayload(pvName)
|
||||
searchHdr := proto.Header{
|
||||
Command: proto.CmdSearch,
|
||||
DataType: proto.SearchReply,
|
||||
DataCount: proto.MinorVersion,
|
||||
Parameter1: searchID,
|
||||
Parameter2: searchID,
|
||||
}
|
||||
searchMsg := proto.BuildMessage(searchHdr, payload)
|
||||
out := make([]byte, 0, proto.HeaderSize+len(searchMsg))
|
||||
out = append(out, verHdr.Bytes()...)
|
||||
out = append(out, searchMsg...)
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeSearchReply builds a CA_PROTO_SEARCH UDP reply.
|
||||
// serverIP may be nil to use 0xFFFFFFFF (sender IP). port is the CA TCP port.
|
||||
func EncodeSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
|
||||
var payload []byte
|
||||
if serverIP != nil {
|
||||
payload = make([]byte, 8)
|
||||
copy(payload[:4], serverIP.To4())
|
||||
binary.BigEndian.PutUint32(payload[4:], 0)
|
||||
} else {
|
||||
payload = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
h := proto.Header{
|
||||
Command: proto.CmdSearch,
|
||||
DataType: uint16(port),
|
||||
Parameter1: proto.MinorVersion,
|
||||
Parameter2: searchID,
|
||||
}
|
||||
return proto.BuildMessage(h, payload)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// buildOldSearchReply builds a CA_PROTO_SEARCH UDP reply using the old
|
||||
// payload format (CA < 4.9): [minor_version(2)][pad(2)][IP(4)].
|
||||
// When ip is nil, the IP field is 0xFFFFFFFF (use-sender convention).
|
||||
func buildOldSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
|
||||
payload := make([]byte, 8)
|
||||
payload[0] = 0x00
|
||||
payload[1] = proto.MinorVersion // e.g. 0x0D = 13
|
||||
// payload[2:4] = 0x00 0x00 (pad)
|
||||
if serverIP == nil {
|
||||
payload[4] = 0xFF
|
||||
payload[5] = 0xFF
|
||||
payload[6] = 0xFF
|
||||
payload[7] = 0xFF
|
||||
} else {
|
||||
copy(payload[4:], serverIP.To4())
|
||||
}
|
||||
h := proto.Header{
|
||||
Command: proto.CmdSearch,
|
||||
DataType: uint16(port),
|
||||
Parameter1: proto.MinorVersion,
|
||||
Parameter2: searchID,
|
||||
}
|
||||
return proto.BuildMessage(h, payload)
|
||||
}
|
||||
|
||||
// TestParseReplyOldFormatAllFF verifies the old format with 0xFFFFFFFF IP.
|
||||
func TestParseReplyOldFormatAllFF(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
const searchID = uint32(42)
|
||||
result := make(chan searchResult, 1)
|
||||
se.mu.Lock()
|
||||
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result}
|
||||
se.mu.Unlock()
|
||||
|
||||
pkt := buildOldSearchReply(searchID, nil, 5064) // nil → 0xFFFFFFFF
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
se.parseReply(pkt, src)
|
||||
|
||||
select {
|
||||
case r := <-result:
|
||||
if r.addr != "127.0.0.1:5064" {
|
||||
t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr)
|
||||
}
|
||||
default:
|
||||
t.Fatal("parseReply did not deliver a result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReplyOldFormatAllZero verifies the old format with 0.0.0.0 IP
|
||||
// (used by some EPICS Base versions to also mean "use sender IP").
|
||||
func TestParseReplyOldFormatAllZero(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
const searchID = uint32(43)
|
||||
result := make(chan searchResult, 1)
|
||||
se.mu.Lock()
|
||||
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result}
|
||||
se.mu.Unlock()
|
||||
|
||||
pkt := buildOldSearchReply(searchID, net.IPv4(0, 0, 0, 0).To4(), 5064)
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
se.parseReply(pkt, src)
|
||||
|
||||
select {
|
||||
case r := <-result:
|
||||
if r.addr != "127.0.0.1:5064" {
|
||||
t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr)
|
||||
}
|
||||
default:
|
||||
t.Fatal("parseReply did not deliver a result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReplyOldFormatExplicitIP verifies that an explicit server IP in
|
||||
// the old payload format is read from the correct offset (bytes [4:8]).
|
||||
func TestParseReplyOldFormatExplicitIP(t *testing.T) {
|
||||
se := &searchEngine{
|
||||
waiters: make(map[uint32]*searchWaiter),
|
||||
}
|
||||
|
||||
const searchID = uint32(7)
|
||||
result := make(chan searchResult, 1)
|
||||
se.mu.Lock()
|
||||
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV2", ch: result}
|
||||
se.mu.Unlock()
|
||||
|
||||
explicitIP := net.ParseIP("10.0.0.5").To4()
|
||||
pkt := buildOldSearchReply(searchID, explicitIP, 5064)
|
||||
src := &net.UDPAddr{IP: net.ParseIP("10.0.0.5"), Port: 5064}
|
||||
|
||||
se.parseReply(pkt, src)
|
||||
|
||||
select {
|
||||
case r := <-result:
|
||||
if r.addr != "10.0.0.5:5064" {
|
||||
t.Errorf("addr = %q, want 10.0.0.5:5064", r.addr)
|
||||
}
|
||||
default:
|
||||
t.Fatal("parseReply did not deliver a result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReplyNewFormat verifies the new payload format (IP at [0:4]).
|
||||
func TestParseReplyNewFormat(t *testing.T) {
|
||||
se := &searchEngine{
|
||||
waiters: make(map[uint32]*searchWaiter),
|
||||
}
|
||||
|
||||
const searchID = uint32(99)
|
||||
result := make(chan searchResult, 1)
|
||||
se.mu.Lock()
|
||||
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV3", ch: result}
|
||||
se.mu.Unlock()
|
||||
|
||||
// New format: IP = 0xFFFFFFFF at [0:4].
|
||||
pkt := EncodeSearchReply(searchID, nil, 5064)
|
||||
src := &net.UDPAddr{IP: net.ParseIP("192.168.1.10"), Port: 5064}
|
||||
|
||||
se.parseReply(pkt, src)
|
||||
|
||||
select {
|
||||
case r := <-result:
|
||||
if r.addr != "192.168.1.10:5064" {
|
||||
t.Errorf("addr = %q, want 192.168.1.10:5064", r.addr)
|
||||
}
|
||||
default:
|
||||
t.Fatal("parseReply did not deliver a result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
// Package testca provides an in-process fake CA server for use in tests.
|
||||
// It supports a small, fixed set of PVs and is compatible with the goca Client.
|
||||
package testca
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// PVSpec describes one test PV hosted by the fake server.
|
||||
type PVSpec struct {
|
||||
Name string
|
||||
DBFType int // proto.DBF* constant
|
||||
Count uint32 // element count (1 for scalars)
|
||||
Value any // initial value
|
||||
Access uint32 // proto.AccessRead | proto.AccessWrite
|
||||
}
|
||||
|
||||
// Server is an in-process fake CA server. It listens on a random TCP port and
|
||||
// an ephemeral UDP port. Use Addr() to get the addresses for client config.
|
||||
type Server struct {
|
||||
tcpLn net.Listener
|
||||
udpCn *net.UDPConn
|
||||
|
||||
mu sync.RWMutex
|
||||
pvs map[string]*serverPV
|
||||
subs map[uint32]*serverSub // subID → sub
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type serverPV struct {
|
||||
spec PVSpec
|
||||
mu sync.RWMutex
|
||||
val any
|
||||
}
|
||||
|
||||
type serverSub struct {
|
||||
pvName string
|
||||
subID uint32
|
||||
cid uint32
|
||||
sid uint32
|
||||
conn net.Conn
|
||||
dbrType uint16
|
||||
count uint32
|
||||
}
|
||||
|
||||
// New creates and starts a fake CA server hosting the given PVs.
|
||||
func New(pvs []PVSpec) (*Server, error) {
|
||||
tcpLn, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("testca: tcp listen: %w", err)
|
||||
}
|
||||
udpCn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
tcpLn.Close()
|
||||
return nil, fmt.Errorf("testca: udp listen: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
tcpLn: tcpLn,
|
||||
udpCn: udpCn,
|
||||
pvs: make(map[string]*serverPV, len(pvs)),
|
||||
subs: make(map[uint32]*serverSub),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
for _, spec := range pvs {
|
||||
s.pvs[spec.Name] = &serverPV{spec: spec, val: spec.Value}
|
||||
}
|
||||
|
||||
go s.acceptLoop()
|
||||
go s.udpLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// TCPAddr returns the TCP "host:port" address clients should connect to.
|
||||
func (s *Server) TCPAddr() string { return s.tcpLn.Addr().String() }
|
||||
|
||||
// UDPAddr returns the UDP "host:port" address clients should search against.
|
||||
func (s *Server) UDPAddr() string { return s.udpCn.LocalAddr().String() }
|
||||
|
||||
// Close shuts down the server.
|
||||
func (s *Server) Close() {
|
||||
close(s.done)
|
||||
s.tcpLn.Close()
|
||||
s.udpCn.Close()
|
||||
}
|
||||
|
||||
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
|
||||
func (s *Server) SetValue(pvName string, val any) error {
|
||||
s.mu.RLock()
|
||||
pv, ok := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("testca: unknown PV %q", pvName)
|
||||
}
|
||||
pv.mu.Lock()
|
||||
pv.val = val
|
||||
pv.mu.Unlock()
|
||||
|
||||
// Push to all subscribers of this PV.
|
||||
s.mu.RLock()
|
||||
for _, sub := range s.subs {
|
||||
if sub.pvName == pvName {
|
||||
if msg := s.buildEventMsg(sub, pv); msg != nil {
|
||||
_, _ = sub.conn.Write(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// UDP search loop //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (s *Server) udpLoop() {
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
_ = s.udpCn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
n, src, err := s.udpCn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.handleUDP(buf[:n], src)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUDP(data []byte, src *net.UDPAddr) {
|
||||
for len(data) >= proto.HeaderSize {
|
||||
hdr, n, err := proto.DecodeHeader(newBytesReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payEnd := n + int(hdr.PayloadSize)
|
||||
if payEnd > len(data) {
|
||||
return
|
||||
}
|
||||
payload := data[n:payEnd]
|
||||
data = data[payEnd:]
|
||||
|
||||
if hdr.Command != proto.CmdSearch {
|
||||
continue
|
||||
}
|
||||
// Extract PV name from payload (null-terminated).
|
||||
pvName := nullStr(payload)
|
||||
|
||||
s.mu.RLock()
|
||||
_, ok := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reply: data_type = TCP port, parameter2 = searchID.
|
||||
tcpPort := s.tcpLn.Addr().(*net.TCPAddr).Port
|
||||
// Use 0xFFFFFFFF IP to tell client to use sender's IP.
|
||||
reply := buildSearchReply(hdr.Parameter2, tcpPort)
|
||||
_, _ = s.udpCn.WriteToUDP(reply, src)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSearchReply(searchID uint32, port int) []byte {
|
||||
payload := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
|
||||
h := proto.Header{
|
||||
Command: proto.CmdSearch,
|
||||
DataType: uint16(port),
|
||||
Parameter1: proto.MinorVersion,
|
||||
Parameter2: searchID,
|
||||
}
|
||||
return proto.BuildMessage(h, payload)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// TCP accept + per-connection handler //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.tcpLn.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// Track CID → (pvName, SID).
|
||||
type chanInfo struct {
|
||||
pvName string
|
||||
sid uint32
|
||||
}
|
||||
sidSeq := uint32(1000)
|
||||
channels := make(map[uint32]*chanInfo) // cid → info
|
||||
|
||||
for {
|
||||
hdr, _, err := proto.DecodeHeader(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var payload []byte
|
||||
if hdr.PayloadSize > 0 {
|
||||
payload = make([]byte, hdr.PayloadSize)
|
||||
if _, err = io.ReadFull(conn, payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch hdr.Command {
|
||||
case proto.CmdVersion:
|
||||
// Respond with VERSION.
|
||||
reply := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdVersion,
|
||||
DataCount: proto.MinorVersion,
|
||||
}, nil)
|
||||
conn.Write(reply)
|
||||
|
||||
case proto.CmdHostName, proto.CmdClientName:
|
||||
// Ignore client identity.
|
||||
|
||||
case proto.CmdCreateChan:
|
||||
cid := hdr.Parameter1
|
||||
pvName := nullStr(payload)
|
||||
|
||||
s.mu.RLock()
|
||||
pv, ok := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
// CREATE_FAIL
|
||||
fail := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdCreateFail,
|
||||
Parameter1: cid,
|
||||
}, nil)
|
||||
conn.Write(fail)
|
||||
continue
|
||||
}
|
||||
|
||||
sid := sidSeq
|
||||
sidSeq++
|
||||
channels[cid] = &chanInfo{pvName: pvName, sid: sid}
|
||||
|
||||
// CREATE_CHAN reply: DataType=dbfType, DataCount=count, p1=cid, p2=sid.
|
||||
pv.mu.RLock()
|
||||
dbfType := pv.spec.DBFType
|
||||
count := pv.spec.Count
|
||||
pv.mu.RUnlock()
|
||||
|
||||
reply := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdCreateChan,
|
||||
DataType: uint16(dbfType),
|
||||
DataCount: count,
|
||||
Parameter1: cid,
|
||||
Parameter2: sid,
|
||||
}, nil)
|
||||
conn.Write(reply)
|
||||
|
||||
// ACCESS_RIGHTS: p1=cid, p2=access.
|
||||
access := pv.spec.Access
|
||||
if access == 0 {
|
||||
access = proto.AccessRead | proto.AccessWrite
|
||||
}
|
||||
ar := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdAccessRights,
|
||||
Parameter1: cid,
|
||||
Parameter2: access,
|
||||
}, nil)
|
||||
conn.Write(ar)
|
||||
|
||||
case proto.CmdEventAdd:
|
||||
// p1=SID (channel), p2=subscriptionID (client-assigned).
|
||||
sid := hdr.Parameter1
|
||||
subID := hdr.Parameter2
|
||||
|
||||
// Find channel by SID.
|
||||
var pvName string
|
||||
for _, info := range channels {
|
||||
if info.sid == sid {
|
||||
pvName = info.pvName
|
||||
break
|
||||
}
|
||||
}
|
||||
if pvName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
sub := &serverSub{
|
||||
pvName: pvName,
|
||||
subID: subID,
|
||||
sid: sid,
|
||||
conn: conn,
|
||||
dbrType: hdr.DataType,
|
||||
count: hdr.DataCount,
|
||||
}
|
||||
s.subs[subID] = sub
|
||||
s.mu.Unlock()
|
||||
|
||||
// Send initial value.
|
||||
s.mu.RLock()
|
||||
pv := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
if msg := s.buildEventMsg(sub, pv); msg != nil {
|
||||
conn.Write(msg)
|
||||
}
|
||||
|
||||
case proto.CmdEventCancel:
|
||||
// p1=SID, p2=subscriptionID.
|
||||
subID := hdr.Parameter2
|
||||
s.mu.Lock()
|
||||
delete(s.subs, subID)
|
||||
s.mu.Unlock()
|
||||
|
||||
case proto.CmdReadNotify:
|
||||
// p1=SID, p2=ioid (per CA spec).
|
||||
sid := hdr.Parameter1
|
||||
ioid := hdr.Parameter2
|
||||
|
||||
var pvName string
|
||||
var cid uint32
|
||||
for c, info := range channels {
|
||||
if info.sid == sid {
|
||||
pvName = info.pvName
|
||||
cid = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if pvName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
pv := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
pv.mu.RLock()
|
||||
val := pv.val
|
||||
pv.mu.RUnlock()
|
||||
|
||||
var replyPayload []byte
|
||||
switch hdr.DataType {
|
||||
case proto.DBRCtrlDouble, proto.DBRCtrlLong, proto.DBRCtrlEnum, proto.DBRCtrlString,
|
||||
proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar:
|
||||
replyPayload = s.encodeCtrlValue(hdr.DataType, pv)
|
||||
default:
|
||||
replyPayload = s.encodeTimeValue(hdr.DataType, hdr.DataCount, val)
|
||||
}
|
||||
reply := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdReadNotify,
|
||||
DataType: hdr.DataType,
|
||||
DataCount: hdr.DataCount,
|
||||
Parameter1: cid, // echo client channel ID (matches real EPICS IOC)
|
||||
Parameter2: ioid, // echo ioid so client can match the reply
|
||||
}, replyPayload)
|
||||
conn.Write(reply)
|
||||
|
||||
case proto.CmdWrite:
|
||||
// p1=SID, p2=0.
|
||||
sid := hdr.Parameter1
|
||||
var pvName string
|
||||
for _, info := range channels {
|
||||
if info.sid == sid {
|
||||
pvName = info.pvName
|
||||
break
|
||||
}
|
||||
}
|
||||
if pvName == "" {
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
pv := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
val := decodeWritePayload(hdr.DataType, payload)
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
pv.mu.Lock()
|
||||
pv.val = val
|
||||
pv.mu.Unlock()
|
||||
|
||||
// Push update to all subscribers.
|
||||
s.mu.RLock()
|
||||
for _, sub := range s.subs {
|
||||
if sub.pvName == pvName {
|
||||
if msg := s.buildEventMsg(sub, pv); msg != nil {
|
||||
sub.conn.Write(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
case proto.CmdEcho:
|
||||
// No response needed (server echoes are optional).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Value encoding helpers //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func (s *Server) buildEventMsg(sub *serverSub, pv *serverPV) []byte {
|
||||
pv.mu.RLock()
|
||||
val := pv.val
|
||||
pv.mu.RUnlock()
|
||||
payload := s.encodeTimeValue(sub.dbrType, sub.count, val)
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
return proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdEventAdd,
|
||||
DataType: sub.dbrType,
|
||||
DataCount: sub.count,
|
||||
Parameter1: 1, // ECA_NORMAL
|
||||
Parameter2: sub.subID,
|
||||
}, payload)
|
||||
}
|
||||
|
||||
// encodeTimeValue builds a DBR_TIME_* payload for val.
|
||||
func (s *Server) encodeTimeValue(dbrType uint16, _ uint32, val any) []byte {
|
||||
now := time.Now().UTC()
|
||||
sec := uint32(now.Unix() - 631152000) // EPICS epoch offset
|
||||
nsec := uint32(now.Nanosecond())
|
||||
|
||||
hdr := make([]byte, 12)
|
||||
// status=0, severity=0
|
||||
binary.BigEndian.PutUint32(hdr[4:], sec)
|
||||
binary.BigEndian.PutUint32(hdr[8:], nsec)
|
||||
|
||||
var body []byte
|
||||
switch dbrType {
|
||||
case proto.DBRTimeDouble:
|
||||
body = make([]byte, 12) // 4 pad + 8 value
|
||||
f := toF64(val)
|
||||
binary.BigEndian.PutUint64(body[4:], math.Float64bits(f))
|
||||
|
||||
case proto.DBRTimeFloat:
|
||||
body = make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(body, math.Float32bits(float32(toF64(val))))
|
||||
|
||||
case proto.DBRTimeLong:
|
||||
body = make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(body, uint32(int32(toF64(val))))
|
||||
|
||||
case proto.DBRTimeShort, proto.DBRTimeEnum:
|
||||
body = make([]byte, 4) // 2-byte RISC pad + 2-byte value
|
||||
binary.BigEndian.PutUint16(body[2:], uint16(int16(toF64(val))))
|
||||
|
||||
case proto.DBRTimeChar:
|
||||
body = make([]byte, 4) // RISC_pad0[0:2] + RISC_pad1[2] + value[3]
|
||||
body[3] = byte(uint8(toF64(val)))
|
||||
|
||||
case proto.DBRTimeString:
|
||||
body = make([]byte, 40)
|
||||
if str, ok := val.(string); ok {
|
||||
copy(body, str)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return append(hdr, body...)
|
||||
}
|
||||
|
||||
// encodeCtrlValue builds a DBR_CTRL_* payload for a GET reply.
|
||||
// The fake server returns minimal but structurally correct payloads so that
|
||||
// the client-side decode functions succeed.
|
||||
func (s *Server) encodeCtrlValue(dbrType uint16, pv *serverPV) []byte {
|
||||
pv.mu.RLock()
|
||||
val := pv.val
|
||||
access := pv.spec.Access
|
||||
pv.mu.RUnlock()
|
||||
|
||||
switch dbrType {
|
||||
case proto.DBRCtrlDouble: // 88 bytes
|
||||
p := make([]byte, 88)
|
||||
if access == proto.AccessRead {
|
||||
// status=0, severity=0 (read-only signalled via ACCESS_RIGHTS, not here)
|
||||
}
|
||||
// units at [8:16] (empty string = "")
|
||||
// value at [80:88]
|
||||
binary.BigEndian.PutUint64(p[80:], math.Float64bits(toF64(val)))
|
||||
return p
|
||||
|
||||
case proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: // map to Long-style
|
||||
// Treat as CtrlLong (48 bytes); caller asked for these types but our
|
||||
// NativeCtrlType maps them to Double/Long anyway. Return a valid stub.
|
||||
p := make([]byte, 48)
|
||||
binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val))))
|
||||
return p
|
||||
|
||||
case proto.DBRCtrlLong: // 48 bytes
|
||||
p := make([]byte, 48)
|
||||
binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val))))
|
||||
return p
|
||||
|
||||
case proto.DBRCtrlEnum: // 424 bytes
|
||||
p := make([]byte, 424)
|
||||
// no_str at [4:6] = 0 (no enum strings in fake server)
|
||||
binary.BigEndian.PutUint16(p[422:], uint16(int16(toF64(val))))
|
||||
return p
|
||||
|
||||
case proto.DBRCtrlString: // 44 bytes: status(2)+severity(2)+value[40]
|
||||
p := make([]byte, 44)
|
||||
if str, ok := val.(string); ok {
|
||||
copy(p[4:], str)
|
||||
}
|
||||
return p
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeWritePayload(dbrType uint16, payload []byte) any {
|
||||
switch dbrType {
|
||||
case proto.DBRDouble:
|
||||
if len(payload) < 8 {
|
||||
return nil
|
||||
}
|
||||
return math.Float64frombits(binary.BigEndian.Uint64(payload))
|
||||
case proto.DBRLong:
|
||||
if len(payload) < 4 {
|
||||
return nil
|
||||
}
|
||||
return int32(binary.BigEndian.Uint32(payload))
|
||||
case proto.DBRShort, proto.DBREnum:
|
||||
if len(payload) < 2 {
|
||||
return nil
|
||||
}
|
||||
return int16(binary.BigEndian.Uint16(payload))
|
||||
case proto.DBRString:
|
||||
return nullStr(payload)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toF64(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int32:
|
||||
return float64(x)
|
||||
case int16:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func nullStr(b []byte) string {
|
||||
for i, c := range b {
|
||||
if c == 0 {
|
||||
return string(b[:i])
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Minimal io.Reader for proto.DecodeHeader //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
type bytesReader struct{ b []byte; i int }
|
||||
|
||||
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
|
||||
|
||||
func (r *bytesReader) Read(p []byte) (int, error) {
|
||||
if r.i >= len(r.b) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.b[r.i:])
|
||||
r.i += n
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package pva
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/gopva/pvdata"
|
||||
)
|
||||
|
||||
// DefaultTCPPort is the standard PVA TCP port for channel connections.
|
||||
const DefaultTCPPort = 5075
|
||||
|
||||
// DefaultUDPPort is the PVA broadcast/search port (distinct from TCP).
|
||||
const DefaultUDPPort = 5076
|
||||
|
||||
// Client is a PVA client that manages connections to one or more servers.
|
||||
// It performs UDP search to locate PVs, then opens TCP connections on demand.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn // server addr → connection
|
||||
pending map[string][]waiter // pvName → waiters for address
|
||||
|
||||
searchSeq atomic.Uint32
|
||||
udp *net.UDPConn
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type waiter struct {
|
||||
pvName string
|
||||
cb func(addr string, err error)
|
||||
}
|
||||
|
||||
// NewClient creates a PVA client. Call Close when done.
|
||||
func NewClient() (*Client, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cl := &Client{
|
||||
conns: make(map[string]*conn),
|
||||
pending: make(map[string][]waiter),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if err := cl.startUDP(); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// Close shuts down the client and all connections.
|
||||
func (cl *Client) Close() {
|
||||
cl.cancel()
|
||||
if cl.udp != nil {
|
||||
cl.udp.Close()
|
||||
}
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
for _, c := range cl.conns {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Get issues a one-shot GET for pvName and returns the decoded structure.
|
||||
func (cl *Client) Get(ctx context.Context, pvName string) (pvdata.StructValue, error) {
|
||||
ch := make(chan struct {
|
||||
v pvdata.StructValue
|
||||
err error
|
||||
}, 1)
|
||||
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
||||
if err != nil {
|
||||
ch <- struct {
|
||||
v pvdata.StructValue
|
||||
err error
|
||||
}{err: err}
|
||||
return
|
||||
}
|
||||
c.get(pvName, func(v pvdata.StructValue, e error) {
|
||||
ch <- struct {
|
||||
v pvdata.StructValue
|
||||
err error
|
||||
}{v, e}
|
||||
})
|
||||
})
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res.v, res.err
|
||||
case <-ctx.Done():
|
||||
return pvdata.StructValue{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor subscribes to pvName and returns a channel that receives updates.
|
||||
// The channel is closed when ctx is cancelled or a fatal error occurs.
|
||||
func (cl *Client) Monitor(ctx context.Context, pvName string) <-chan MonitorEvent {
|
||||
ch := make(chan MonitorEvent, 16)
|
||||
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
||||
if err != nil {
|
||||
ch <- MonitorEvent{Err: err}
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
c.subscribe(ctx, pvName, ch)
|
||||
})
|
||||
return ch
|
||||
}
|
||||
|
||||
// ---- Internal --------------------------------------------------------
|
||||
|
||||
// withConn locates (or creates) a connection to the server hosting pvName
|
||||
// and calls cb on it. cb may be called from a goroutine.
|
||||
func (cl *Client) withConn(ctx context.Context, pvName string, cb func(*conn, error)) {
|
||||
cl.search(ctx, pvName, func(addr string, err error) {
|
||||
if err != nil {
|
||||
cb(nil, err)
|
||||
return
|
||||
}
|
||||
cl.mu.Lock()
|
||||
c, ok := cl.conns[addr]
|
||||
cl.mu.Unlock()
|
||||
if ok {
|
||||
cb(c, nil)
|
||||
return
|
||||
}
|
||||
// open new TCP connection
|
||||
tc, err := dial(ctx, addr)
|
||||
if err != nil {
|
||||
cb(nil, fmt.Errorf("pva: connect %s: %w", addr, err))
|
||||
return
|
||||
}
|
||||
cl.mu.Lock()
|
||||
cl.conns[addr] = tc
|
||||
cl.mu.Unlock()
|
||||
cb(tc, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- UDP search -------------------------------------------------------
|
||||
|
||||
func (cl *Client) startUDP() error {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pva: UDP listen: %w", err)
|
||||
}
|
||||
cl.udp = conn
|
||||
go cl.udpReadLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// search sends a PVA search request and calls cb when a response arrives.
|
||||
// Currently does broadcast + localhost; production code would also check
|
||||
// EPICS_PVA_ADDR_LIST environment variable.
|
||||
func (cl *Client) search(ctx context.Context, pvName string, cb func(string, error)) {
|
||||
seq := cl.searchSeq.Add(1)
|
||||
|
||||
cl.mu.Lock()
|
||||
cl.pending[pvName] = append(cl.pending[pvName], waiter{pvName: pvName, cb: cb})
|
||||
cl.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cl.mu.Lock()
|
||||
cl.removeWaiter(pvName, cb)
|
||||
cl.mu.Unlock()
|
||||
cb("", ctx.Err())
|
||||
return
|
||||
default:
|
||||
}
|
||||
cl.sendSearchRequest(seq, pvName)
|
||||
// exponential backoff: 100ms, 200ms, 400ms … up to 2s
|
||||
delay := time.Duration(100<<min(attempt, 4)) * time.Millisecond
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
cl.mu.Lock()
|
||||
cl.removeWaiter(pvName, cb)
|
||||
cl.mu.Unlock()
|
||||
cb("", ctx.Err())
|
||||
return
|
||||
}
|
||||
}
|
||||
// give up
|
||||
cl.mu.Lock()
|
||||
cl.removeWaiter(pvName, cb)
|
||||
cl.mu.Unlock()
|
||||
cb("", fmt.Errorf("pva: search timeout for %q", pvName))
|
||||
}()
|
||||
}
|
||||
|
||||
func (cl *Client) removeWaiter(pvName string, cb func(string, error)) {
|
||||
ws := cl.pending[pvName]
|
||||
for i, w := range ws {
|
||||
// compare by pointer (closure identity via fmt trick)
|
||||
if fmt.Sprintf("%p", w.cb) == fmt.Sprintf("%p", cb) {
|
||||
cl.pending[pvName] = append(ws[:i], ws[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendSearchRequest broadcasts a PVA SEARCH request.
|
||||
func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
|
||||
// SEARCH payload (spec §6.3):
|
||||
// searchSeqID(4) + flags(1) + reserved(3) + responseAddress(16) + responsePort(2)
|
||||
// + transportCount(1) + transports[](1=TCP) + pvCount(2) + pvs[](channelID(4)+name(str))
|
||||
var buf bytes.Buffer
|
||||
binary.Write(&buf, binary.LittleEndian, seq) // searchSeqID
|
||||
buf.WriteByte(0x81) // flags: unicast=0, reply=1, mustReply=1
|
||||
buf.Write([]byte{0, 0, 0}) // reserved
|
||||
// responseAddress: 16 bytes (IPv4-mapped IPv6: ::ffff:0.0.0.0 = no specific address)
|
||||
buf.Write(make([]byte, 12))
|
||||
buf.Write([]byte{0, 0, 0, 0}) // 0.0.0.0 → server will reply to source addr
|
||||
|
||||
// responsePort: our UDP listening port
|
||||
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
|
||||
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
|
||||
|
||||
buf.WriteByte(1) // transportCount = 1
|
||||
buf.WriteByte(0x01) // transport[0]: TCP
|
||||
|
||||
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
|
||||
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
|
||||
pvdata.WriteString(&buf, pvName)
|
||||
|
||||
msg := BuildMessage(CmdSearchRequest, flagApp, buf.Bytes())
|
||||
|
||||
// Send to localhost and broadcast on the PVA UDP search port (5076).
|
||||
targets := []string{
|
||||
fmt.Sprintf("127.0.0.1:%d", DefaultUDPPort),
|
||||
fmt.Sprintf("255.255.255.255:%d", DefaultUDPPort),
|
||||
}
|
||||
// Also check EPICS_PVA_ADDR_LIST if set (common in labs).
|
||||
// Entries without an explicit port default to DefaultTCPPort for TCP
|
||||
// connections; for the search broadcast we still use the UDP port.
|
||||
for _, addr := range addrsFromEnv() {
|
||||
targets = append(targets, fmt.Sprintf("%s:%d", addr, DefaultUDPPort))
|
||||
}
|
||||
for _, addr := range targets {
|
||||
dst, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := cl.udp.WriteTo(msg, dst); err != nil {
|
||||
slog.Debug("pva search send", "dst", addr, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// udpReadLoop reads SEARCH_RESPONSE (and BEACON) UDP messages.
|
||||
func (cl *Client) udpReadLoop() {
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, src, err := cl.udp.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cl.handleUDP(buf[:n], src)
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *Client) handleUDP(data []byte, src *net.UDPAddr) {
|
||||
if len(data) < headerSize {
|
||||
return
|
||||
}
|
||||
r := bytes.NewReader(data)
|
||||
hdr, err := ReadHeader(r)
|
||||
if err != nil || hdr.isControl() {
|
||||
return
|
||||
}
|
||||
if hdr.Command != CmdSearchResponse {
|
||||
return
|
||||
}
|
||||
|
||||
payload := make([]byte, hdr.Size)
|
||||
if _, err := r.Read(payload); err != nil {
|
||||
return
|
||||
}
|
||||
pr := bytes.NewReader(payload)
|
||||
|
||||
// SEARCH_RESPONSE:
|
||||
// guid(12) + searchSeqID(4) + serverAddress(16) + serverPort(2)
|
||||
// + protocol(str) + found(1) + pvCount(2) + pvIDs[](4)
|
||||
var guid [12]byte
|
||||
pr.Read(guid[:])
|
||||
var seq uint32
|
||||
binary.Read(pr, binary.LittleEndian, &seq)
|
||||
var addr [16]byte
|
||||
pr.Read(addr[:])
|
||||
var port uint16
|
||||
binary.Read(pr, binary.LittleEndian, &port)
|
||||
proto, _ := readPVAString(pr, binary.LittleEndian)
|
||||
if proto != "tcp" {
|
||||
return
|
||||
}
|
||||
found, _ := pr.ReadByte()
|
||||
if found == 0 {
|
||||
return
|
||||
}
|
||||
var pvCount uint16
|
||||
binary.Read(pr, binary.LittleEndian, &pvCount)
|
||||
// pvIDs — we match by searching for waiters by name (seq used as channelID)
|
||||
|
||||
// Determine server address: use src if serverAddress is 0.0.0.0
|
||||
ip := net.IP(addr[12:16]) // last 4 bytes = IPv4
|
||||
if ip.Equal(net.IPv4zero) {
|
||||
ip = src.IP
|
||||
}
|
||||
serverAddr := fmt.Sprintf("%s:%d", ip.String(), port)
|
||||
|
||||
// Notify all pending waiters (simple: we got a response so all PVs are on this server)
|
||||
cl.mu.Lock()
|
||||
var toNotify []func(string, error)
|
||||
for pvName, ws := range cl.pending {
|
||||
for _, w := range ws {
|
||||
toNotify = append(toNotify, w.cb)
|
||||
}
|
||||
delete(cl.pending, pvName)
|
||||
}
|
||||
cl.mu.Unlock()
|
||||
|
||||
for _, cb := range toNotify {
|
||||
go cb(serverAddr, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
package pva
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/gopva/pvdata"
|
||||
)
|
||||
|
||||
// ---- Channel/Request state machine -----------------------------------
|
||||
|
||||
type chanState int
|
||||
|
||||
const (
|
||||
chanPending chanState = iota // CREATE_CHANNEL sent
|
||||
chanCreated // server ack'd
|
||||
chanFailed // CREATE_CHANNEL failed
|
||||
)
|
||||
|
||||
type serverChannel struct {
|
||||
pvName string
|
||||
cid uint32 // client channel ID
|
||||
sid uint32 // server channel ID (assigned on creation)
|
||||
state chanState
|
||||
|
||||
// pending callbacks waiting for channel creation
|
||||
onCreate []func(sid uint32, err error)
|
||||
}
|
||||
|
||||
type monitorSub struct {
|
||||
ioid uint32
|
||||
sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
|
||||
cid uint32
|
||||
desc pvdata.FieldDesc // structure descriptor (received on INIT response)
|
||||
updates chan MonitorEvent
|
||||
}
|
||||
|
||||
// MonitorEvent carries a decoded monitor update.
|
||||
type MonitorEvent struct {
|
||||
// Changed is the set of top-level field indices that changed.
|
||||
Changed pvdata.BitSet
|
||||
// Value is the full decoded structure value.
|
||||
Value pvdata.StructValue
|
||||
// Err is non-nil if this is a terminal error event.
|
||||
Err error
|
||||
}
|
||||
|
||||
// conn is a single PVA TCP connection to one server.
|
||||
type conn struct {
|
||||
nc net.Conn
|
||||
br *bufio.Reader // shared reader — used by handshake then readLoop
|
||||
bw *bufio.Writer
|
||||
mu sync.Mutex // protects writes + state maps
|
||||
|
||||
// auto-incrementing IDs
|
||||
nextCID atomic.Uint32
|
||||
nextIOID atomic.Uint32
|
||||
|
||||
// state
|
||||
chans map[uint32]*serverChannel // keyed by client CID
|
||||
byPV map[string]*serverChannel // keyed by PV name
|
||||
monitors map[uint32]*monitorSub // keyed by IOID
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func dial(ctx context.Context, addr string) (*conn, error) {
|
||||
nc, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &conn{
|
||||
nc: nc,
|
||||
br: bufio.NewReader(nc),
|
||||
bw: bufio.NewWriter(nc),
|
||||
chans: make(map[uint32]*serverChannel),
|
||||
byPV: make(map[string]*serverChannel),
|
||||
monitors: make(map[uint32]*monitorSub),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if err := c.handshake(); err != nil {
|
||||
nc.Close()
|
||||
return nil, err
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ---- Handshake --------------------------------------------------------
|
||||
|
||||
// handshake completes the PVA connection setup.
|
||||
//
|
||||
// Protocol sequence (spec §4.2):
|
||||
// 1. Client → server: SET_BYTE_ORDER control (announces little-endian)
|
||||
// 2. Server → client: SET_BYTE_ORDER control (may be omitted by some servers)
|
||||
// 3. Server → client: CONNECTION_VALIDATION (0x01) — server's auth options
|
||||
// 4. Client → server: CONNECTION_VALIDATION (0x01) — client selects auth
|
||||
//
|
||||
// The client MUST NOT send step 4 before receiving step 3.
|
||||
func (c *conn) handshake() error {
|
||||
// Step 1: announce our byte order.
|
||||
if _, err := c.nc.Write(BuildMessage(CtrlSetByteOrder, flagControl, nil)); err != nil {
|
||||
return fmt.Errorf("pva handshake: send byte-order: %w", err)
|
||||
}
|
||||
|
||||
// Step 2+3: read server messages until CONNECTION_VALIDATION arrives.
|
||||
// Servers typically send SET_BYTE_ORDER first, then CONNECTION_VALIDATION.
|
||||
for {
|
||||
hdr, err := ReadHeader(c.br)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pva handshake: read server message: %w", err)
|
||||
}
|
||||
payload := make([]byte, hdr.Size)
|
||||
if _, err := io.ReadFull(c.br, payload); err != nil {
|
||||
return fmt.Errorf("pva handshake: read server payload: %w", err)
|
||||
}
|
||||
if hdr.isControl() {
|
||||
// SET_BYTE_ORDER from server — we always use LE, ignore.
|
||||
continue
|
||||
}
|
||||
if hdr.Command == CmdConnectionValid {
|
||||
break // server is ready for our reply
|
||||
}
|
||||
// Ignore unexpected messages (e.g. BEACON on same port).
|
||||
}
|
||||
|
||||
// Step 4: send CONNECTION_VALIDATION response.
|
||||
// payload: clientReceiveBufferSize(4) + clientIntrospectionRegistryMaxSize(4) + authNZ(str)
|
||||
var buf bytes.Buffer
|
||||
binary.Write(&buf, binary.LittleEndian, uint32(0x00800000)) // 8 MiB receive buffer
|
||||
binary.Write(&buf, binary.LittleEndian, uint32(0x00000000)) // introspection registry size
|
||||
buf.WriteByte(0) // authNZ = "" (anonymous)
|
||||
if _, err := c.nc.Write(BuildMessage(CmdConnectionValid, flagApp, buf.Bytes())); err != nil {
|
||||
return fmt.Errorf("pva handshake: send connection_valid: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Write helpers ----------------------------------------------------
|
||||
|
||||
func (c *conn) send(msg []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.nc.Write(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Channel creation -------------------------------------------------
|
||||
|
||||
// openChannel ensures a channel for pvName exists and calls cb when ready.
|
||||
func (c *conn) openChannel(pvName string, cb func(sid uint32, err error)) {
|
||||
c.mu.Lock()
|
||||
if ch, ok := c.byPV[pvName]; ok {
|
||||
switch ch.state {
|
||||
case chanCreated:
|
||||
sid := ch.sid
|
||||
c.mu.Unlock()
|
||||
cb(sid, nil)
|
||||
return
|
||||
case chanFailed:
|
||||
c.mu.Unlock()
|
||||
cb(0, fmt.Errorf("pva: channel %q creation failed", pvName))
|
||||
return
|
||||
default:
|
||||
ch.onCreate = append(ch.onCreate, cb)
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
cid := c.nextCID.Add(1)
|
||||
ch := &serverChannel{pvName: pvName, cid: cid, state: chanPending, onCreate: []func(uint32, error){cb}}
|
||||
c.chans[cid] = ch
|
||||
c.byPV[pvName] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
// send CREATE_CHANNEL
|
||||
// payload: count(2) + cid(4) + pvName(string)
|
||||
var payload bytes.Buffer
|
||||
binary.Write(&payload, binary.LittleEndian, uint16(1)) // channel count = 1
|
||||
binary.Write(&payload, binary.LittleEndian, cid)
|
||||
pvdata.WriteString(&payload, pvName)
|
||||
if err := c.send(BuildMessage(CmdCreateChannel, flagApp, payload.Bytes())); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.chans, cid)
|
||||
delete(c.byPV, pvName)
|
||||
c.mu.Unlock()
|
||||
cb(0, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Monitor ----------------------------------------------------------
|
||||
|
||||
// subscribe sends an EVENT_ADD equivalent (MONITOR INIT) for pvName.
|
||||
func (c *conn) subscribe(ctx context.Context, pvName string, ch chan MonitorEvent) {
|
||||
c.openChannel(pvName, func(sid uint32, err error) {
|
||||
if err != nil {
|
||||
ch <- MonitorEvent{Err: err}
|
||||
return
|
||||
}
|
||||
ioid := c.nextIOID.Add(1)
|
||||
|
||||
// Build MONITOR INIT payload:
|
||||
// sid(4) + ioid(4) + subCmd(1=INIT) + pvRequest(FieldDesc+Value)
|
||||
var payload bytes.Buffer
|
||||
binary.Write(&payload, binary.LittleEndian, sid)
|
||||
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||
payload.WriteByte(SubCmdInit) // subCmd = INIT
|
||||
|
||||
// pvRequest: empty structure (field "field" selecting all)
|
||||
// Encoded as: TypeCodeStruct + typeID("") + nfields(0)
|
||||
// This selects the whole top-level structure.
|
||||
payload.WriteByte(pvdata.TypeCodeStruct) // FieldDesc type
|
||||
pvdata.WriteString(&payload, "") // typeID
|
||||
pvdata.WriteSize(&payload, 0) // no sub-fields = select all
|
||||
|
||||
c.mu.Lock()
|
||||
var cid uint32
|
||||
for _, sch := range c.chans {
|
||||
if sch.sid == sid {
|
||||
cid = sch.cid
|
||||
break
|
||||
}
|
||||
}
|
||||
ms := &monitorSub{ioid: ioid, sid: sid, cid: cid, updates: ch}
|
||||
c.monitors[ioid] = ms
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.send(BuildMessage(CmdMonitor, flagApp, payload.Bytes())); err != nil {
|
||||
ch <- MonitorEvent{Err: err}
|
||||
}
|
||||
|
||||
// watch context cancellation → send MONITOR DESTROY
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.cancelMonitor(ioid, sid)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *conn) cancelMonitor(ioid, sid uint32) {
|
||||
var payload bytes.Buffer
|
||||
binary.Write(&payload, binary.LittleEndian, sid)
|
||||
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||
payload.WriteByte(SubCmdDEstroy)
|
||||
_ = c.send(BuildMessage(CmdMonitor, flagApp, payload.Bytes()))
|
||||
}
|
||||
|
||||
// ---- GET (one-shot) ---------------------------------------------------
|
||||
|
||||
// get issues a GET for pvName and calls cb when the response arrives.
|
||||
func (c *conn) get(pvName string, cb func(v pvdata.StructValue, err error)) {
|
||||
c.openChannel(pvName, func(sid uint32, err error) {
|
||||
if err != nil {
|
||||
cb(pvdata.StructValue{}, err)
|
||||
return
|
||||
}
|
||||
ioid := c.nextIOID.Add(1)
|
||||
|
||||
// INIT phase: GET with subCmd=INIT only (spec §7.2 — INIT and GET are separate messages)
|
||||
var payload bytes.Buffer
|
||||
binary.Write(&payload, binary.LittleEndian, sid)
|
||||
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||
payload.WriteByte(SubCmdInit) // INIT only — server returns FieldDesc
|
||||
// pvRequest: empty struct = select all fields
|
||||
payload.WriteByte(pvdata.TypeCodeStruct)
|
||||
pvdata.WriteString(&payload, "")
|
||||
pvdata.WriteSize(&payload, 0)
|
||||
|
||||
c.mu.Lock()
|
||||
ms := &monitorSub{ioid: ioid, sid: sid, updates: make(chan MonitorEvent, 1)}
|
||||
c.monitors[ioid] = ms
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.send(BuildMessage(CmdGet, flagApp, payload.Bytes())); err != nil {
|
||||
cb(pvdata.StructValue{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
// wait for INIT response, then issue GET
|
||||
go func() {
|
||||
evt := <-ms.updates
|
||||
if evt.Err != nil {
|
||||
cb(pvdata.StructValue{}, evt.Err)
|
||||
return
|
||||
}
|
||||
// INIT done — send GET sub-command
|
||||
var p2 bytes.Buffer
|
||||
binary.Write(&p2, binary.LittleEndian, sid)
|
||||
binary.Write(&p2, binary.LittleEndian, ioid)
|
||||
p2.WriteByte(SubCmdGet)
|
||||
if err := c.send(BuildMessage(CmdGet, flagApp, p2.Bytes())); err != nil {
|
||||
cb(pvdata.StructValue{}, err)
|
||||
return
|
||||
}
|
||||
// wait for data response
|
||||
evt = <-ms.updates
|
||||
cb(evt.Value, evt.Err)
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.monitors, ioid)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Read loop --------------------------------------------------------
|
||||
|
||||
func (c *conn) readLoop() {
|
||||
defer close(c.done)
|
||||
for {
|
||||
hdr, err := ReadHeader(c.br)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
slog.Debug("pva readLoop", "err", err)
|
||||
}
|
||||
c.closeAllWithError(err)
|
||||
return
|
||||
}
|
||||
payload := make([]byte, hdr.Size)
|
||||
if _, err := io.ReadFull(c.br, payload); err != nil {
|
||||
c.closeAllWithError(err)
|
||||
return
|
||||
}
|
||||
r := bytes.NewReader(payload)
|
||||
bo := hdr.byteOrder()
|
||||
|
||||
if hdr.isControl() {
|
||||
c.handleControl(hdr, payload)
|
||||
continue
|
||||
}
|
||||
switch hdr.Command {
|
||||
case CmdCreateChannel:
|
||||
c.handleCreateChannel(r, bo)
|
||||
case CmdGet:
|
||||
c.handleGet(r, bo)
|
||||
case CmdMonitor:
|
||||
c.handleMonitor(r, bo)
|
||||
case CmdDestroyChannel:
|
||||
// server killed the channel; clean up
|
||||
case CmdMessage:
|
||||
handleServerMessage(r, bo)
|
||||
default:
|
||||
// ignore unknown commands
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conn) handleControl(hdr PVAHeader, payload []byte) {
|
||||
switch hdr.Command {
|
||||
case CtrlSetByteOrder:
|
||||
// server's byte-order announcement — we always use LE so ignore
|
||||
case CtrlEchoRequest:
|
||||
// respond with ECHO_RESPONSE
|
||||
_ = c.send(BuildMessage(CtrlEchoResponse, flagControl, payload))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateChannel decodes a CREATE_CHANNEL response.
|
||||
// Wire: count(2) × { cid(4) + status(1+…) + sid(4) }
|
||||
func (c *conn) handleCreateChannel(r io.Reader, bo binary.ByteOrder) {
|
||||
var count uint16
|
||||
binary.Read(r, bo, &count)
|
||||
for i := 0; i < int(count); i++ {
|
||||
var cid uint32
|
||||
binary.Read(r, bo, &cid)
|
||||
st, err := ReadStatus(r, bo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var sid uint32
|
||||
if st.OK() {
|
||||
binary.Read(r, bo, &sid)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ch, ok := c.chans[cid]
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
cbs := ch.onCreate
|
||||
ch.onCreate = nil
|
||||
if st.OK() {
|
||||
ch.sid = sid
|
||||
ch.state = chanCreated
|
||||
} else {
|
||||
ch.state = chanFailed
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var cbErr error
|
||||
if !st.OK() {
|
||||
cbErr = fmt.Errorf("pva: %s", st.Error())
|
||||
}
|
||||
for _, cb := range cbs {
|
||||
cb(sid, cbErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleGet decodes a GET response (both INIT and data phases).
|
||||
func (c *conn) handleGet(r io.Reader, bo binary.ByteOrder) {
|
||||
var ioid uint32
|
||||
binary.Read(r, bo, &ioid)
|
||||
var subCmdBuf [1]byte
|
||||
io.ReadFull(r, subCmdBuf[:])
|
||||
subCmd := subCmdBuf[0]
|
||||
|
||||
st, err := ReadStatus(r, bo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ms, ok := c.monitors[ioid]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if subCmd&SubCmdInit != 0 {
|
||||
// INIT response — read FieldDesc
|
||||
if st.OK() {
|
||||
desc, err := pvdata.ReadFieldDesc(r)
|
||||
if err == nil {
|
||||
ms.desc = desc
|
||||
}
|
||||
}
|
||||
ms.updates <- MonitorEvent{Err: asError(st)}
|
||||
return
|
||||
}
|
||||
|
||||
// GET data response
|
||||
if !st.OK() {
|
||||
ms.updates <- MonitorEvent{Err: fmt.Errorf("pva GET: %s", st.Error())}
|
||||
return
|
||||
}
|
||||
// read bitSet (changed mask) then value
|
||||
changed, err := pvdata.ReadBitSet(r)
|
||||
if err != nil {
|
||||
ms.updates <- MonitorEvent{Err: err}
|
||||
return
|
||||
}
|
||||
v, err := pvdata.ReadValue(r, ms.desc)
|
||||
if err != nil {
|
||||
ms.updates <- MonitorEvent{Err: err}
|
||||
return
|
||||
}
|
||||
sv, _ := v.(pvdata.StructValue)
|
||||
ms.updates <- MonitorEvent{Changed: changed, Value: sv}
|
||||
}
|
||||
|
||||
// handleMonitor decodes a MONITOR response.
|
||||
// Sub-commands: INIT (0x08) → FieldDesc, pipeline data.
|
||||
func (c *conn) handleMonitor(r io.Reader, bo binary.ByteOrder) {
|
||||
var ioid uint32
|
||||
binary.Read(r, bo, &ioid)
|
||||
var subCmdBuf2 [1]byte
|
||||
io.ReadFull(r, subCmdBuf2[:])
|
||||
subCmd := subCmdBuf2[0]
|
||||
|
||||
c.mu.Lock()
|
||||
ms, ok := c.monitors[ioid]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if subCmd&SubCmdInit != 0 {
|
||||
st, err := ReadStatus(r, bo)
|
||||
if err != nil || !st.OK() {
|
||||
ms.updates <- MonitorEvent{Err: asError(st)}
|
||||
return
|
||||
}
|
||||
desc, err := pvdata.ReadFieldDesc(r)
|
||||
if err != nil {
|
||||
ms.updates <- MonitorEvent{Err: err}
|
||||
return
|
||||
}
|
||||
ms.desc = desc
|
||||
// Acknowledge pipeline
|
||||
c.sendMonitorAck(ioid)
|
||||
return
|
||||
}
|
||||
|
||||
if subCmd&SubCmdDEstroy != 0 {
|
||||
close(ms.updates)
|
||||
c.mu.Lock()
|
||||
delete(c.monitors, ioid)
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Data update: changed BitSet + overrun BitSet + value
|
||||
changed, err := pvdata.ReadBitSet(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = pvdata.ReadBitSet(r) // overrun — discard for now
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
v, err := pvdata.ReadValue(r, ms.desc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sv, _ := v.(pvdata.StructValue)
|
||||
select {
|
||||
case ms.updates <- MonitorEvent{Changed: changed, Value: sv}:
|
||||
default:
|
||||
// slow consumer — drop (overrun)
|
||||
}
|
||||
// Acknowledge to allow more updates (pipeline)
|
||||
c.sendMonitorAck(ioid)
|
||||
}
|
||||
|
||||
func (c *conn) sendMonitorAck(ioid uint32) {
|
||||
// MONITOR pipeline ACK: sid(4) + ioid(4) + subCmd(0x80)
|
||||
c.mu.Lock()
|
||||
ms, ok := c.monitors[ioid]
|
||||
sid := uint32(0)
|
||||
if ok {
|
||||
sid = ms.sid
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var p bytes.Buffer
|
||||
binary.Write(&p, binary.LittleEndian, sid)
|
||||
binary.Write(&p, binary.LittleEndian, ioid)
|
||||
p.WriteByte(SubCmdPipeline)
|
||||
_ = c.send(BuildMessage(CmdMonitor, flagApp, p.Bytes()))
|
||||
}
|
||||
|
||||
func handleServerMessage(r io.Reader, bo binary.ByteOrder) {
|
||||
// MESSAGE: type(1) + message(string) — log and ignore
|
||||
var t [1]byte
|
||||
r.Read(t[:])
|
||||
msg, _ := readPVAString(r, bo)
|
||||
slog.Debug("pva server message", "type", t[0], "msg", msg)
|
||||
}
|
||||
|
||||
func (c *conn) closeAllWithError(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, ms := range c.monitors {
|
||||
select {
|
||||
case ms.updates <- MonitorEvent{Err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conn) close() {
|
||||
c.nc.Close()
|
||||
select {
|
||||
case <-c.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func asError(s Status) error {
|
||||
if s.OK() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", s.Error())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package pva
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// addrsFromEnv returns addresses from EPICS_PVA_ADDR_LIST, if set.
|
||||
func addrsFromEnv() []string {
|
||||
v := os.Getenv("EPICS_PVA_ADDR_LIST")
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
var addrs []string
|
||||
for _, a := range strings.Fields(v) {
|
||||
if a != "" {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/uopi/gopva
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package pva implements an EPICS PV Access (PVA) client in pure Go.
|
||||
//
|
||||
// PVA is the modern EPICS network protocol introduced in EPICS 7, replacing
|
||||
// Channel Access (CA) for structured/typed data. This package implements
|
||||
// the client side of the PVA TCP protocol sufficient for GET and MONITOR
|
||||
// operations on NTScalar and NTScalarArray normative types.
|
||||
//
|
||||
// Reference: EPICS PVAccess Protocol Specification r1.1
|
||||
// https://epics-pvdata.sourceforge.net/pvAccess.html
|
||||
package pva
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ---- Message flags ----------------------------------------------------
|
||||
|
||||
const (
|
||||
flagApp byte = 0x00 // application message (vs control)
|
||||
flagControl byte = 0x01 // control message
|
||||
flagSegFirst byte = 0x08 // first segment
|
||||
flagSegLast byte = 0x10 // last segment
|
||||
flagSegMid byte = 0x18 // middle segment
|
||||
flagFromServer byte = 0x40 // direction: server→client
|
||||
flagBigEndian byte = 0x80 // byte order: big-endian (we use little-endian)
|
||||
)
|
||||
|
||||
// ---- Application message command codes --------------------------------
|
||||
|
||||
const (
|
||||
CmdBeacon byte = 0x00
|
||||
CmdConnectionValid byte = 0x01
|
||||
CmdEcho byte = 0x02
|
||||
CmdSearchRequest byte = 0x03
|
||||
CmdSearchResponse byte = 0x04
|
||||
CmdAuthNZ byte = 0x05 // authentication/authorisation
|
||||
CmdAclChange byte = 0x06
|
||||
CmdCreateChannel byte = 0x07
|
||||
CmdDestroyChannel byte = 0x08
|
||||
CmdConnectionReq byte = 0x09
|
||||
CmdGet byte = 0x0A
|
||||
CmdPut byte = 0x0B
|
||||
CmdPutGet byte = 0x0C
|
||||
CmdMonitor byte = 0x0D
|
||||
CmdArray byte = 0x0E
|
||||
CmdDestroyReq byte = 0x0F
|
||||
CmdProcess byte = 0x10
|
||||
CmdGetField byte = 0x11
|
||||
CmdMessage byte = 0x12 // server status/warning
|
||||
CmdMultipleData byte = 0x13
|
||||
CmdRpcCall byte = 0x14
|
||||
CmdCancelRequest byte = 0x15
|
||||
CmdOriginTag byte = 0x16
|
||||
)
|
||||
|
||||
// ---- Control message sub-commands -------------------------------------
|
||||
|
||||
const (
|
||||
CtrlSetMarker byte = 0x00
|
||||
CtrlAckMarker byte = 0x01
|
||||
CtrlSetByteOrder byte = 0x02
|
||||
CtrlEchoRequest byte = 0x03
|
||||
CtrlEchoResponse byte = 0x04
|
||||
)
|
||||
|
||||
// ---- Status codes -----------------------------------------------------
|
||||
|
||||
const (
|
||||
StatusOK byte = 0xFF // special "OK" short encoding
|
||||
StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
|
||||
StatusWarn byte = 0x01
|
||||
StatusError byte = 0x02
|
||||
StatusFatal byte = 0x03
|
||||
)
|
||||
|
||||
// ---- Request sub-command bits -----------------------------------------
|
||||
|
||||
const (
|
||||
SubCmdInit byte = 0x08 // initialise request (send FieldDesc)
|
||||
SubCmdGet byte = 0x40 // GET sub-command
|
||||
SubCmdPipeline byte = 0x80 // pipeline (for monitor)
|
||||
SubCmdDEstroy byte = 0x10 // destroy request
|
||||
SubCmdProcess byte = 0x04
|
||||
)
|
||||
|
||||
// ---- Header -----------------------------------------------------------
|
||||
|
||||
// PVAHeader is the 8-byte fixed header on every PVA message.
|
||||
//
|
||||
// byte 0 : magic 0xCA
|
||||
// byte 1 : protocol version (0x01)
|
||||
// byte 2 : flags (see flag* constants)
|
||||
// byte 3 : command code
|
||||
// bytes 4–7 : payload size (uint32, matches byte-order flag)
|
||||
type PVAHeader struct {
|
||||
Version byte
|
||||
Flags byte
|
||||
Command byte
|
||||
Size uint32
|
||||
}
|
||||
|
||||
const magic byte = 0xCA
|
||||
const headerSize = 8
|
||||
|
||||
func (h PVAHeader) isLittleEndian() bool { return h.Flags&flagBigEndian == 0 }
|
||||
func (h PVAHeader) isFromServer() bool { return h.Flags&flagFromServer != 0 }
|
||||
func (h PVAHeader) isControl() bool { return h.Flags&flagControl != 0 }
|
||||
|
||||
// byteOrder returns the binary.ByteOrder matching the header flags.
|
||||
func (h PVAHeader) byteOrder() binary.ByteOrder {
|
||||
if h.isLittleEndian() {
|
||||
return binary.LittleEndian
|
||||
}
|
||||
return binary.BigEndian
|
||||
}
|
||||
|
||||
// ReadHeader reads an 8-byte PVA message header from r.
|
||||
func ReadHeader(r io.Reader) (PVAHeader, error) {
|
||||
var raw [headerSize]byte
|
||||
if _, err := io.ReadFull(r, raw[:]); err != nil {
|
||||
return PVAHeader{}, err
|
||||
}
|
||||
if raw[0] != magic {
|
||||
return PVAHeader{}, fmt.Errorf("pva: bad magic 0x%02X (expected 0xCA)", raw[0])
|
||||
}
|
||||
h := PVAHeader{
|
||||
Version: raw[1],
|
||||
Flags: raw[2],
|
||||
Command: raw[3],
|
||||
}
|
||||
bo := h.byteOrder()
|
||||
h.Size = bo.Uint32(raw[4:8])
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// WriteHeader encodes h and writes the 8-byte header to w.
|
||||
// Always writes in little-endian (client sends LE after SetByteOrder).
|
||||
func WriteHeader(w io.Writer, h PVAHeader) error {
|
||||
raw := [headerSize]byte{
|
||||
magic,
|
||||
h.Version,
|
||||
h.Flags,
|
||||
h.Command,
|
||||
}
|
||||
binary.LittleEndian.PutUint32(raw[4:8], h.Size)
|
||||
_, err := w.Write(raw[:])
|
||||
return err
|
||||
}
|
||||
|
||||
// BuildMessage assembles a complete PVA message (header + payload).
|
||||
func BuildMessage(cmd byte, flags byte, payload []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
_ = WriteHeader(&buf, PVAHeader{
|
||||
Version: 1,
|
||||
Flags: flags & ^flagFromServer, // clear server bit — this is client
|
||||
Command: cmd,
|
||||
Size: uint32(len(payload)),
|
||||
})
|
||||
buf.Write(payload)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ---- Status decoding ---------------------------------------------------
|
||||
|
||||
// Status is a decoded PVA status message embedded in many response types.
|
||||
type Status struct {
|
||||
Type byte
|
||||
Message string
|
||||
Stack string
|
||||
}
|
||||
|
||||
// OK reports whether the status is success.
|
||||
func (s Status) OK() bool { return s.Type == StatusOKFull || s.Type == StatusOK }
|
||||
|
||||
// Error implements the error interface so Status can be returned as error.
|
||||
func (s Status) Error() string {
|
||||
if s.OK() {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("pva status %d: %s", s.Type, s.Message)
|
||||
}
|
||||
|
||||
// ReadStatus reads a PVA status from r.
|
||||
// The short form (0xFF = OK) is handled transparently.
|
||||
func ReadStatus(r io.Reader, bo binary.ByteOrder) (Status, error) {
|
||||
var b [1]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if b[0] == StatusOK {
|
||||
return Status{Type: StatusOKFull}, nil
|
||||
}
|
||||
typ := b[0]
|
||||
msg, err := readPVAString(r, bo)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
stack, err := readPVAString(r, bo)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{Type: typ, Message: msg, Stack: stack}, nil
|
||||
}
|
||||
|
||||
// readPVAString reads a PVA length-prefixed string.
|
||||
// PVA uses compact size encoding (same as pvdata package).
|
||||
func readPVAString(r io.Reader, _ binary.ByteOrder) (string, error) {
|
||||
// delegate to pvdata compact-size reader
|
||||
var b [1]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n := int(b[0])
|
||||
if n == 0xFF {
|
||||
var v int32
|
||||
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n = int(v)
|
||||
}
|
||||
if n <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package pvdata
|
||||
|
||||
import "io"
|
||||
|
||||
// BitSet is a variable-length set of bit indices, encoded on the wire as:
|
||||
// compact-size(n_bytes) + n_bytes of little-endian bits.
|
||||
// Bit 0 of byte 0 is field index 0, bit 1 of byte 0 is field index 1, etc.
|
||||
// Used in pvData Monitor responses for "changed" and "overrun" masks.
|
||||
type BitSet struct {
|
||||
bits []byte
|
||||
}
|
||||
|
||||
// NewBitSet returns an empty BitSet.
|
||||
func NewBitSet() BitSet { return BitSet{} }
|
||||
|
||||
// Set sets bit i in the BitSet.
|
||||
func (bs *BitSet) Set(i int) {
|
||||
byteIdx := i / 8
|
||||
for len(bs.bits) <= byteIdx {
|
||||
bs.bits = append(bs.bits, 0)
|
||||
}
|
||||
bs.bits[byteIdx] |= 1 << (uint(i) % 8)
|
||||
}
|
||||
|
||||
// Has reports whether bit i is set.
|
||||
func (bs *BitSet) Has(i int) bool {
|
||||
byteIdx := i / 8
|
||||
if byteIdx >= len(bs.bits) {
|
||||
return false
|
||||
}
|
||||
return bs.bits[byteIdx]&(1<<(uint(i)%8)) != 0
|
||||
}
|
||||
|
||||
// ReadBitSet reads a BitSet from r.
|
||||
func ReadBitSet(r io.Reader) (BitSet, error) {
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return BitSet{}, err
|
||||
}
|
||||
if n == 0 {
|
||||
return BitSet{}, nil
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return BitSet{}, err
|
||||
}
|
||||
return BitSet{bits: buf}, nil
|
||||
}
|
||||
|
||||
// WriteBitSet writes bs to w.
|
||||
func WriteBitSet(w io.Writer, bs BitSet) error {
|
||||
if err := WriteSize(w, int64(len(bs.bits))); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bs.bits) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := w.Write(bs.bits)
|
||||
return err
|
||||
}
|
||||
|
||||
// FieldIndices returns all set bit indices in ascending order.
|
||||
func (bs *BitSet) FieldIndices() []int {
|
||||
var out []int
|
||||
for bi, b := range bs.bits {
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
if b&(1<<uint(bit)) != 0 {
|
||||
out = append(out, bi*8+bit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// Package pvdata implements the EPICS pvData serialisation layer.
|
||||
//
|
||||
// pvData defines a rich, self-describing type system used by PV Access (PVA).
|
||||
// Every value that flows over a PVA connection is encoded according to a
|
||||
// FieldDesc (field description / introspection interface), which is itself
|
||||
// serialised on the wire before the payload.
|
||||
//
|
||||
// Reference: EPICS pvData Specification r1.1
|
||||
// https://epics-pvdata.sourceforge.net/pvData.html
|
||||
package pvdata
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ---- Type codes -------------------------------------------------------
|
||||
//
|
||||
// pvData uses a one-byte "type code" to identify scalar and array element
|
||||
// types. Values from the spec §3.1.
|
||||
|
||||
const (
|
||||
TypeCodeBoolean byte = 0x00
|
||||
TypeCodeByte byte = 0x20 // int8
|
||||
TypeCodeShort byte = 0x21 // int16
|
||||
TypeCodeInt byte = 0x22 // int32
|
||||
TypeCodeLong byte = 0x23 // int64
|
||||
TypeCodeUByte byte = 0x24 // uint8
|
||||
TypeCodeUShort byte = 0x25 // uint16
|
||||
TypeCodeUInt byte = 0x26 // uint32
|
||||
TypeCodeULong byte = 0x27 // uint64
|
||||
TypeCodeFloat byte = 0x42 // float32
|
||||
TypeCodeDouble byte = 0x43 // float64
|
||||
TypeCodeString byte = 0x60
|
||||
TypeCodeStruct byte = 0x80
|
||||
TypeCodeUnion byte = 0x81
|
||||
TypeCodeBoolArr byte = 0x08
|
||||
TypeCodeByteArr byte = 0x28
|
||||
TypeCodeShortArr byte = 0x29
|
||||
TypeCodeIntArr byte = 0x2A
|
||||
TypeCodeLongArr byte = 0x2B
|
||||
TypeCodeUByteArr byte = 0x2C
|
||||
TypeCodeUShortArr byte = 0x2D
|
||||
TypeCodeUIntArr byte = 0x2E
|
||||
TypeCodeULongArr byte = 0x2F
|
||||
TypeCodeFloatArr byte = 0x4A
|
||||
TypeCodeDoubleArr byte = 0x4B
|
||||
TypeCodeStringArr byte = 0x68
|
||||
TypeCodeStructArr byte = 0x88
|
||||
TypeCodeUnionArr byte = 0x89
|
||||
TypeCodeVariant byte = 0x82 // any
|
||||
TypeCodeNull byte = 0xFF
|
||||
)
|
||||
|
||||
// ---- Compact size encoding --------------------------------------------
|
||||
//
|
||||
// pvData encodes array/string lengths as a "size" using 1, 4, or 8 bytes
|
||||
// (spec §3.2).
|
||||
|
||||
// ReadSize reads a compact-format size from r.
|
||||
// 0x00–0xFE → 1-byte value
|
||||
// 0xFF → read 4-byte int32 (negative = 8-byte int64 follows)
|
||||
func ReadSize(r io.Reader) (int64, error) {
|
||||
var b [1]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if b[0] != 0xFF {
|
||||
return int64(b[0]), nil
|
||||
}
|
||||
var v int32
|
||||
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if v >= 0 {
|
||||
return int64(v), nil
|
||||
}
|
||||
// extended 8-byte size
|
||||
var v8 int64
|
||||
if err := binary.Read(r, binary.LittleEndian, &v8); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v8, nil
|
||||
}
|
||||
|
||||
// WriteSize writes n as a compact-format size to w.
|
||||
func WriteSize(w io.Writer, n int64) error {
|
||||
if n < 0xFF {
|
||||
_, err := w.Write([]byte{byte(n)})
|
||||
return err
|
||||
}
|
||||
if n <= math.MaxInt32 {
|
||||
if _, err := w.Write([]byte{0xFF}); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Write(w, binary.LittleEndian, int32(n))
|
||||
}
|
||||
// rare: >2 GiB array
|
||||
if _, err := w.Write([]byte{0xFF}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, int32(-1)); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Write(w, binary.LittleEndian, n)
|
||||
}
|
||||
|
||||
// ReadString reads a pvData string (compact size + UTF-8 bytes).
|
||||
func ReadString(r io.Reader) (string, error) {
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
return "", nil
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// WriteString writes a pvData string to w.
|
||||
func WriteString(w io.Writer, s string) error {
|
||||
if err := WriteSize(w, int64(len(s))); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(w, s)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Field description (introspection) --------------------------------
|
||||
|
||||
// Kind classifies a FieldDesc at a high level.
|
||||
type Kind byte
|
||||
|
||||
const (
|
||||
KindScalar Kind = iota // single scalar value
|
||||
KindScalarArr // variable-length array of scalars
|
||||
KindString // pvData string (treated specially)
|
||||
KindStringArr
|
||||
KindStruct // structure
|
||||
KindStructArr
|
||||
KindUnion // discriminated union
|
||||
KindUnionArr
|
||||
KindVariant // any (variant)
|
||||
)
|
||||
|
||||
// FieldDesc describes the type of a single field in a pvData structure.
|
||||
type FieldDesc struct {
|
||||
Kind Kind
|
||||
TypeCode byte // scalar/array type code; 0 for struct/union
|
||||
TypeID string // optional structure/union type ID string
|
||||
Fields []Field // child fields for struct/union
|
||||
}
|
||||
|
||||
// Field is a named FieldDesc (one member of a struct or union).
|
||||
type Field struct {
|
||||
Name string
|
||||
Desc FieldDesc
|
||||
}
|
||||
|
||||
// ---- FieldDesc codec --------------------------------------------------
|
||||
|
||||
// ReadFieldDesc decodes a FieldDesc from r.
|
||||
// This follows the pvData introspection encoding (spec §4).
|
||||
func ReadFieldDesc(r io.Reader) (FieldDesc, error) {
|
||||
var tc [1]byte
|
||||
if _, err := io.ReadFull(r, tc[:]); err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
return readFieldDescFromTypeCode(r, tc[0])
|
||||
}
|
||||
|
||||
func readFieldDescFromTypeCode(r io.Reader, tc byte) (FieldDesc, error) {
|
||||
switch {
|
||||
case tc == TypeCodeNull:
|
||||
return FieldDesc{TypeCode: TypeCodeNull}, nil
|
||||
|
||||
case tc == TypeCodeString:
|
||||
return FieldDesc{Kind: KindString, TypeCode: tc}, nil
|
||||
|
||||
case tc == TypeCodeStringArr:
|
||||
return FieldDesc{Kind: KindStringArr, TypeCode: tc}, nil
|
||||
|
||||
case tc == TypeCodeVariant:
|
||||
return FieldDesc{Kind: KindVariant, TypeCode: tc}, nil
|
||||
|
||||
case tc == TypeCodeStruct || tc == TypeCodeUnion:
|
||||
kind := KindStruct
|
||||
if tc == TypeCodeUnion {
|
||||
kind = KindUnion
|
||||
}
|
||||
typeID, err := ReadString(r)
|
||||
if err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
nfields, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
fields := make([]Field, nfields)
|
||||
for i := range fields {
|
||||
name, err := ReadString(r)
|
||||
if err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
desc, err := ReadFieldDesc(r)
|
||||
if err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
fields[i] = Field{Name: name, Desc: desc}
|
||||
}
|
||||
return FieldDesc{Kind: kind, TypeCode: tc, TypeID: typeID, Fields: fields}, nil
|
||||
|
||||
case tc == TypeCodeStructArr || tc == TypeCodeUnionArr:
|
||||
kind := KindStructArr
|
||||
if tc == TypeCodeUnionArr {
|
||||
kind = KindUnionArr
|
||||
}
|
||||
// element descriptor follows
|
||||
elem, err := ReadFieldDesc(r)
|
||||
if err != nil {
|
||||
return FieldDesc{}, err
|
||||
}
|
||||
return FieldDesc{Kind: kind, TypeCode: tc, TypeID: elem.TypeID, Fields: elem.Fields}, nil
|
||||
|
||||
// scalar array types: 0x28–0x2F, 0x4A–0x4B, 0x08, 0x68
|
||||
case isScalarArrayCode(tc):
|
||||
return FieldDesc{Kind: KindScalarArr, TypeCode: tc}, nil
|
||||
|
||||
// plain scalars and boolean
|
||||
default:
|
||||
return FieldDesc{Kind: KindScalar, TypeCode: tc}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func isScalarArrayCode(tc byte) bool {
|
||||
switch tc {
|
||||
case TypeCodeBoolArr,
|
||||
TypeCodeByteArr, TypeCodeShortArr, TypeCodeIntArr, TypeCodeLongArr,
|
||||
TypeCodeUByteArr, TypeCodeUShortArr, TypeCodeUIntArr, TypeCodeULongArr,
|
||||
TypeCodeFloatArr, TypeCodeDoubleArr,
|
||||
TypeCodeStringArr:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WriteFieldDesc encodes fd into w.
|
||||
func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
|
||||
if _, err := w.Write([]byte{fd.TypeCode}); err != nil {
|
||||
return err
|
||||
}
|
||||
switch fd.Kind {
|
||||
case KindStruct, KindUnion:
|
||||
if err := WriteString(w, fd.TypeID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteSize(w, int64(len(fd.Fields))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range fd.Fields {
|
||||
if err := WriteString(w, f.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteFieldDesc(w, f.Desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case KindStructArr, KindUnionArr:
|
||||
// write element descriptor
|
||||
elemTC := TypeCodeStruct
|
||||
if fd.Kind == KindUnionArr {
|
||||
elemTC = TypeCodeUnion
|
||||
}
|
||||
if err := WriteFieldDesc(w, FieldDesc{
|
||||
Kind: KindStruct, TypeCode: byte(elemTC),
|
||||
TypeID: fd.TypeID, Fields: fd.Fields,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Value codec -------------------------------------------------------
|
||||
|
||||
// Value is a decoded pvData value. We use interface{} for flexibility;
|
||||
// callers type-assert to concrete types listed below.
|
||||
//
|
||||
// Scalar types:
|
||||
// bool, int8, int16, int32, int64,
|
||||
// uint8, uint16, uint32, uint64,
|
||||
// float32, float64, string
|
||||
//
|
||||
// Array types: []T for each scalar T above; []string.
|
||||
// Struct: StructValue
|
||||
// Union: UnionValue
|
||||
// Variant: VariantValue
|
||||
// Null: nil
|
||||
type Value = interface{}
|
||||
|
||||
// StructValue holds the fields of a decoded pvData structure.
|
||||
type StructValue struct {
|
||||
TypeID string
|
||||
Fields []FieldValue
|
||||
}
|
||||
|
||||
// FieldValue is a named value within a StructValue.
|
||||
type FieldValue struct {
|
||||
Name string
|
||||
Value Value
|
||||
}
|
||||
|
||||
// UnionValue holds a selected field from a discriminated union.
|
||||
type UnionValue struct {
|
||||
Selected int // index into union's Fields slice
|
||||
Name string // field name
|
||||
Value Value
|
||||
}
|
||||
|
||||
// VariantValue wraps an any-typed value together with its runtime descriptor.
|
||||
type VariantValue struct {
|
||||
Desc FieldDesc
|
||||
Value Value
|
||||
}
|
||||
|
||||
// ReadValue decodes a value conforming to desc from r.
|
||||
func ReadValue(r io.Reader, desc FieldDesc) (Value, error) {
|
||||
switch desc.Kind {
|
||||
case KindScalar:
|
||||
return readScalar(r, desc.TypeCode)
|
||||
case KindScalarArr:
|
||||
return readScalarArray(r, desc.TypeCode)
|
||||
case KindString:
|
||||
return ReadString(r)
|
||||
case KindStringArr:
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss := make([]string, n)
|
||||
for i := range ss {
|
||||
ss[i], err = ReadString(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return ss, nil
|
||||
case KindStruct:
|
||||
return readStruct(r, desc)
|
||||
case KindStructArr:
|
||||
return readStructArr(r, desc)
|
||||
case KindUnion:
|
||||
return readUnion(r, desc)
|
||||
case KindUnionArr:
|
||||
return readUnionArr(r, desc)
|
||||
case KindVariant:
|
||||
return readVariant(r)
|
||||
default:
|
||||
return nil, fmt.Errorf("pvdata: unknown kind %d", desc.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func readScalar(r io.Reader, tc byte) (Value, error) {
|
||||
switch tc {
|
||||
case TypeCodeBoolean:
|
||||
var b [1]byte
|
||||
_, err := io.ReadFull(r, b[:])
|
||||
return b[0] != 0, err
|
||||
case TypeCodeByte:
|
||||
var v int8
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeShort:
|
||||
var v int16
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeInt:
|
||||
var v int32
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeLong:
|
||||
var v int64
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeUByte:
|
||||
var v uint8
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeUShort:
|
||||
var v uint16
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeUInt:
|
||||
var v uint32
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeULong:
|
||||
var v uint64
|
||||
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||
case TypeCodeFloat:
|
||||
var v uint32
|
||||
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return math.Float32frombits(v), nil
|
||||
case TypeCodeDouble:
|
||||
var v uint64
|
||||
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return math.Float64frombits(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("pvdata: unknown scalar type code 0x%02X", tc)
|
||||
}
|
||||
}
|
||||
|
||||
func readScalarArray(r io.Reader, tc byte) (Value, error) {
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := int(n)
|
||||
switch tc {
|
||||
case TypeCodeBoolArr:
|
||||
v := make([]bool, count)
|
||||
for i := range v {
|
||||
b := [1]byte{}
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v[i] = b[0] != 0
|
||||
}
|
||||
return v, nil
|
||||
case TypeCodeByteArr:
|
||||
v := make([]int8, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeShortArr:
|
||||
v := make([]int16, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeIntArr:
|
||||
v := make([]int32, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeLongArr:
|
||||
v := make([]int64, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeUByteArr:
|
||||
v := make([]uint8, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeUShortArr:
|
||||
v := make([]uint16, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeUIntArr:
|
||||
v := make([]uint32, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeULongArr:
|
||||
v := make([]uint64, count)
|
||||
return v, binary.Read(r, binary.LittleEndian, v)
|
||||
case TypeCodeFloatArr:
|
||||
bits := make([]uint32, count)
|
||||
if err := binary.Read(r, binary.LittleEndian, bits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := make([]float32, count)
|
||||
for i, b := range bits {
|
||||
v[i] = math.Float32frombits(b)
|
||||
}
|
||||
return v, nil
|
||||
case TypeCodeDoubleArr:
|
||||
bits := make([]uint64, count)
|
||||
if err := binary.Read(r, binary.LittleEndian, bits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := make([]float64, count)
|
||||
for i, b := range bits {
|
||||
v[i] = math.Float64frombits(b)
|
||||
}
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("pvdata: unknown array type code 0x%02X", tc)
|
||||
}
|
||||
}
|
||||
|
||||
func readStruct(r io.Reader, desc FieldDesc) (StructValue, error) {
|
||||
sv := StructValue{TypeID: desc.TypeID, Fields: make([]FieldValue, len(desc.Fields))}
|
||||
for i, f := range desc.Fields {
|
||||
v, err := ReadValue(r, f.Desc)
|
||||
if err != nil {
|
||||
return StructValue{}, fmt.Errorf("field %q: %w", f.Name, err)
|
||||
}
|
||||
sv.Fields[i] = FieldValue{Name: f.Name, Value: v}
|
||||
}
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func readStructArr(r io.Reader, desc FieldDesc) ([]StructValue, error) {
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr := make([]StructValue, n)
|
||||
for i := range arr {
|
||||
sv, err := readStruct(r, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = sv
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func readUnion(r io.Reader, desc FieldDesc) (UnionValue, error) {
|
||||
idx, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return UnionValue{}, err
|
||||
}
|
||||
if idx < 0 || int(idx) >= len(desc.Fields) {
|
||||
return UnionValue{Selected: int(idx)}, nil // null selector
|
||||
}
|
||||
f := desc.Fields[idx]
|
||||
v, err := ReadValue(r, f.Desc)
|
||||
if err != nil {
|
||||
return UnionValue{}, err
|
||||
}
|
||||
return UnionValue{Selected: int(idx), Name: f.Name, Value: v}, nil
|
||||
}
|
||||
|
||||
func readUnionArr(r io.Reader, desc FieldDesc) ([]UnionValue, error) {
|
||||
n, err := ReadSize(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr := make([]UnionValue, n)
|
||||
for i := range arr {
|
||||
uv, err := readUnion(r, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = uv
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func readVariant(r io.Reader) (VariantValue, error) {
|
||||
desc, err := ReadFieldDesc(r)
|
||||
if err != nil {
|
||||
return VariantValue{}, err
|
||||
}
|
||||
v, err := ReadValue(r, desc)
|
||||
if err != nil {
|
||||
return VariantValue{}, err
|
||||
}
|
||||
return VariantValue{Desc: desc, Value: v}, nil
|
||||
}
|
||||
|
||||
// WriteValue encodes v (which must match desc) into w.
|
||||
func WriteValue(w io.Writer, desc FieldDesc, v Value) error {
|
||||
switch desc.Kind {
|
||||
case KindScalar:
|
||||
return writeScalar(w, desc.TypeCode, v)
|
||||
case KindScalarArr:
|
||||
return writeScalarArray(w, desc.TypeCode, v)
|
||||
case KindString:
|
||||
return WriteString(w, v.(string))
|
||||
case KindStringArr:
|
||||
ss := v.([]string)
|
||||
if err := WriteSize(w, int64(len(ss))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range ss {
|
||||
if err := WriteString(w, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case KindStruct:
|
||||
return writeStruct(w, desc, v.(StructValue))
|
||||
case KindUnion:
|
||||
return writeUnion(w, v.(UnionValue))
|
||||
case KindVariant:
|
||||
vv := v.(VariantValue)
|
||||
if err := WriteFieldDesc(w, vv.Desc); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteValue(w, vv.Desc, vv.Value)
|
||||
default:
|
||||
return fmt.Errorf("pvdata: WriteValue unsupported kind %d", desc.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func writeScalar(w io.Writer, tc byte, v Value) error {
|
||||
switch tc {
|
||||
case TypeCodeBoolean:
|
||||
b := byte(0)
|
||||
if v.(bool) {
|
||||
b = 1
|
||||
}
|
||||
_, err := w.Write([]byte{b})
|
||||
return err
|
||||
case TypeCodeFloat:
|
||||
return binary.Write(w, binary.LittleEndian, math.Float32bits(v.(float32)))
|
||||
case TypeCodeDouble:
|
||||
return binary.Write(w, binary.LittleEndian, math.Float64bits(v.(float64)))
|
||||
default:
|
||||
return binary.Write(w, binary.LittleEndian, v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeScalarArray(w io.Writer, tc byte, v Value) error {
|
||||
switch tc {
|
||||
case TypeCodeDoubleArr:
|
||||
arr := v.([]float64)
|
||||
if err := WriteSize(w, int64(len(arr))); err != nil {
|
||||
return err
|
||||
}
|
||||
bits := make([]uint64, len(arr))
|
||||
for i, f := range arr {
|
||||
bits[i] = math.Float64bits(f)
|
||||
}
|
||||
return binary.Write(w, binary.LittleEndian, bits)
|
||||
case TypeCodeFloatArr:
|
||||
arr := v.([]float32)
|
||||
if err := WriteSize(w, int64(len(arr))); err != nil {
|
||||
return err
|
||||
}
|
||||
bits := make([]uint32, len(arr))
|
||||
for i, f := range arr {
|
||||
bits[i] = math.Float32bits(f)
|
||||
}
|
||||
return binary.Write(w, binary.LittleEndian, bits)
|
||||
default:
|
||||
// for integer arrays, v is already []intN or []uintN
|
||||
if err := WriteSize(w, int64(arrayLen(v))); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Write(w, binary.LittleEndian, v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStruct(w io.Writer, desc FieldDesc, sv StructValue) error {
|
||||
for i, fv := range sv.Fields {
|
||||
if err := WriteValue(w, desc.Fields[i].Desc, fv.Value); err != nil {
|
||||
return fmt.Errorf("field %q: %w", fv.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeUnion(w io.Writer, uv UnionValue) error {
|
||||
if err := WriteSize(w, int64(uv.Selected)); err != nil {
|
||||
return err
|
||||
}
|
||||
// caller must supply desc to encode value; not needed if Selected is null
|
||||
return nil
|
||||
}
|
||||
|
||||
// arrayLen returns reflect-free length for the supported array types.
|
||||
func arrayLen(v Value) int {
|
||||
switch x := v.(type) {
|
||||
case []bool:
|
||||
return len(x)
|
||||
case []int8:
|
||||
return len(x)
|
||||
case []int16:
|
||||
return len(x)
|
||||
case []int32:
|
||||
return len(x)
|
||||
case []int64:
|
||||
return len(x)
|
||||
case []uint8:
|
||||
return len(x)
|
||||
case []uint16:
|
||||
return len(x)
|
||||
case []uint32:
|
||||
return len(x)
|
||||
case []uint64:
|
||||
return len(x)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package pvdata_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/gopva/pvdata"
|
||||
)
|
||||
|
||||
// ---- Compact size encoding -------------------------------------------
|
||||
|
||||
func TestCompactSize(t *testing.T) {
|
||||
cases := []int64{0, 1, 10, 254, 255, 256, 1000, 65535, 70000, 1<<31 - 1}
|
||||
for _, n := range cases {
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteSize(&buf, n); err != nil {
|
||||
t.Fatalf("WriteSize(%d): %v", n, err)
|
||||
}
|
||||
got, err := pvdata.ReadSize(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSize for %d: %v", n, err)
|
||||
}
|
||||
if got != n {
|
||||
t.Errorf("round-trip size %d → %d", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- String encoding ------------------------------------------------
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
cases := []string{"", "hello", "UOPI:TICK", "a string with spaces and unicode: é"}
|
||||
for _, s := range cases {
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteString(&buf, s); err != nil {
|
||||
t.Fatalf("WriteString(%q): %v", s, err)
|
||||
}
|
||||
got, err := pvdata.ReadString(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadString for %q: %v", s, err)
|
||||
}
|
||||
if got != s {
|
||||
t.Errorf("round-trip string %q → %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FieldDesc (introspection) round-trip ---------------------------
|
||||
|
||||
func TestFieldDescScalar(t *testing.T) {
|
||||
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteFieldDesc(&buf, desc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadFieldDesc(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TypeCode != desc.TypeCode || got.Kind != desc.Kind {
|
||||
t.Errorf("scalar round-trip: got %+v, want %+v", got, desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldDescStruct(t *testing.T) {
|
||||
// Minimal NTScalar-like struct: value(double) + timeStamp(struct{…})
|
||||
desc := pvdata.FieldDesc{
|
||||
Kind: pvdata.KindStruct,
|
||||
TypeCode: pvdata.TypeCodeStruct,
|
||||
TypeID: "epics:nt/NTScalar:1.0",
|
||||
Fields: []pvdata.Field{
|
||||
{Name: "value", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
|
||||
{Name: "alarm", Desc: pvdata.FieldDesc{
|
||||
Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "alarm_t",
|
||||
Fields: []pvdata.Field{
|
||||
{Name: "severity", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||
{Name: "status", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||
{Name: "message", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
|
||||
},
|
||||
}},
|
||||
{Name: "timeStamp", Desc: pvdata.FieldDesc{
|
||||
Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "time_t",
|
||||
Fields: []pvdata.Field{
|
||||
{Name: "secondsPastEpoch", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeLong}},
|
||||
{Name: "nanoseconds", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||
{Name: "userTag", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteFieldDesc(&buf, desc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadFieldDesc(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TypeID != desc.TypeID {
|
||||
t.Errorf("typeID: got %q want %q", got.TypeID, desc.TypeID)
|
||||
}
|
||||
if len(got.Fields) != len(desc.Fields) {
|
||||
t.Fatalf("field count: got %d want %d", len(got.Fields), len(desc.Fields))
|
||||
}
|
||||
for i, f := range desc.Fields {
|
||||
if got.Fields[i].Name != f.Name {
|
||||
t.Errorf("field[%d] name: got %q want %q", i, got.Fields[i].Name, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Value round-trip -----------------------------------------------
|
||||
|
||||
func TestValueDouble(t *testing.T) {
|
||||
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
|
||||
var v pvdata.Value = float64(3.14159)
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadValue(&buf, desc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.(float64) != v.(float64) {
|
||||
t.Errorf("double round-trip: got %v want %v", got, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueIntArray(t *testing.T) {
|
||||
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeIntArr}
|
||||
var v pvdata.Value = []int32{1, 2, 3, -7, 1<<30}
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadValue(&buf, desc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arr := got.([]int32)
|
||||
src := v.([]int32)
|
||||
if len(arr) != len(src) {
|
||||
t.Fatalf("int32 array length: got %d want %d", len(arr), len(src))
|
||||
}
|
||||
for i := range src {
|
||||
if arr[i] != src[i] {
|
||||
t.Errorf("int32[%d]: got %d want %d", i, arr[i], src[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueStruct(t *testing.T) {
|
||||
desc := pvdata.FieldDesc{
|
||||
Kind: pvdata.KindStruct,
|
||||
TypeCode: pvdata.TypeCodeStruct,
|
||||
TypeID: "test_t",
|
||||
Fields: []pvdata.Field{
|
||||
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
|
||||
{Name: "n", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||
{Name: "s", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
|
||||
},
|
||||
}
|
||||
sv := pvdata.StructValue{
|
||||
TypeID: "test_t",
|
||||
Fields: []pvdata.FieldValue{
|
||||
{Name: "x", Value: float64(2.718)},
|
||||
{Name: "n", Value: int32(42)},
|
||||
{Name: "s", Value: "hello"},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteValue(&buf, desc, sv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadValue(&buf, desc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gsv := got.(pvdata.StructValue)
|
||||
if gsv.Fields[0].Value.(float64) != sv.Fields[0].Value.(float64) {
|
||||
t.Errorf("x field mismatch")
|
||||
}
|
||||
if gsv.Fields[1].Value.(int32) != sv.Fields[1].Value.(int32) {
|
||||
t.Errorf("n field mismatch")
|
||||
}
|
||||
if gsv.Fields[2].Value.(string) != sv.Fields[2].Value.(string) {
|
||||
t.Errorf("s field mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BitSet ---------------------------------------------------------
|
||||
|
||||
func TestBitSet(t *testing.T) {
|
||||
var bs pvdata.BitSet
|
||||
bs.Set(0)
|
||||
bs.Set(3)
|
||||
bs.Set(15)
|
||||
bs.Set(16)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pvdata.WriteBitSet(&buf, bs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pvdata.ReadBitSet(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, idx := range []int{0, 3, 15, 16} {
|
||||
if !got.Has(idx) {
|
||||
t.Errorf("BitSet missing index %d after round-trip", idx)
|
||||
}
|
||||
}
|
||||
for _, idx := range []int{1, 2, 4, 14, 17} {
|
||||
if got.Has(idx) {
|
||||
t.Errorf("BitSet has unexpected index %d", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ func main() {
|
||||
// Copy uPlot CSS and favicon
|
||||
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css"))
|
||||
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.svg"))
|
||||
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.ico"))
|
||||
|
||||
// Write index.html
|
||||
html := `<!doctype html>
|
||||
@@ -88,6 +89,7 @@ func main() {
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uopi</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/uplot.css" />
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
|
||||
+30
-2
@@ -2,11 +2,39 @@
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces"
|
||||
|
||||
# ── Identity & access control ───────────────────────────────────────────────
|
||||
# The end-user identity is read from a header set by a trusted authenticating
|
||||
# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
|
||||
# SECURITY: only enable this when the proxy strips any client-supplied value of
|
||||
# this header, otherwise it can be spoofed.
|
||||
trusted_user_header = "" # e.g. "X-Forwarded-User"
|
||||
# Identity used when the trusted header is absent/empty. Empty = anonymous.
|
||||
default_user = ""
|
||||
|
||||
# Every user is trusted with full write access by default. The blacklist
|
||||
# downgrades specific users. Levels: "readonly" (view only, no writes) or
|
||||
# "noaccess" (denied entirely).
|
||||
# [[server.blacklist]]
|
||||
# user = "guest"
|
||||
# level = "readonly"
|
||||
|
||||
# Named sets of users, referenced by per-panel sharing rules.
|
||||
# [[groups]]
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
|
||||
# Optional: restrict who may add or edit panel logic (the <logic> block of
|
||||
# interfaces) AND server-side control logic. Entries are usernames or group
|
||||
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
|
||||
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
|
||||
# logic_editors = ["operators", "alice"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||
archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665/
|
||||
archive_url = "" # EPICS Archive Appliance base URL
|
||||
channel_finder_url = "" # e.g. http://channelfinder:8080/ChannelFinder
|
||||
auto_sync_filter = "" # e.g. area=StorageRing or tags=production
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
|
||||
+38
-10
@@ -4,7 +4,9 @@ import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import type { Interface } from './lib/types';
|
||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||
import type { Interface, Me } from './lib/types';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
|
||||
@@ -15,6 +17,10 @@ export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
// Interface to show when returning to view mode after editing
|
||||
const [viewTarget, setViewTarget] = useState<Interface | null>(null);
|
||||
// Resolved identity + global access level for the current user.
|
||||
const [me, setMe] = useState<Me>(DEFAULT_ME);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
@@ -24,7 +30,9 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
applyZoom(getStoredZoom());
|
||||
if (!fsParam) wsClient.connect('/ws');
|
||||
fetchMe().then(setMe);
|
||||
}, []);
|
||||
|
||||
if (fsParam) {
|
||||
@@ -36,20 +44,40 @@ export default function App() {
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
function exitEdit() {
|
||||
// `iface === null` means the editor discarded an unsaved/new panel; keep
|
||||
// showing whatever was previously open (held in viewTarget).
|
||||
function exitEdit(iface: Interface | null) {
|
||||
if (iface) setViewTarget(iface);
|
||||
setMode('view');
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
if (!canRead(me.level)) {
|
||||
return (
|
||||
<div>
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view'
|
||||
? <ViewMode onEdit={enterEdit} />
|
||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||
}
|
||||
<div class="app-root">
|
||||
<div class="access-denied">
|
||||
<h1>Access denied</h1>
|
||||
<p>
|
||||
Your account{me.user ? ` (${me.user})` : ''} does not have permission
|
||||
to access this application. Contact an administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={me}>
|
||||
<div class="app-root">
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view' ? (
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
|
||||
) : (
|
||||
<EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+110
-6
@@ -1,5 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { logicEngine } from './lib/logic';
|
||||
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
@@ -14,6 +17,9 @@ import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import LogicDialogs from './LogicDialogs';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
@@ -37,6 +43,32 @@ interface CtxState {
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
// Wraps a single view-mode widget so panel logic (action.widget) can drive it:
|
||||
// `hidden` removes it from the view, `disabled` dims it and blocks interaction
|
||||
// via a transparent overlay (which still surfaces the right-click menu). Plot
|
||||
// pause/clear are handled inside PlotWidget itself.
|
||||
function WidgetView({ widget, children, onContextMenu }: {
|
||||
widget: Widget;
|
||||
children: any;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
key?: string;
|
||||
}) {
|
||||
const [cmd, setCmd] = useState<WidgetCmd>(() => getWidgetCmdStore(widget.id).get());
|
||||
useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]);
|
||||
if (cmd.hidden) return null;
|
||||
if (!cmd.disabled) return children;
|
||||
return (
|
||||
<Fragment>
|
||||
{children}
|
||||
<div
|
||||
class="widget-disable-overlay"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
@@ -47,6 +79,18 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||
visible: false, x: 0, y: 0, signal: null,
|
||||
});
|
||||
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
|
||||
|
||||
// Instantiate this panel's local state variables from their initial values.
|
||||
useEffect(() => {
|
||||
initLocalState(iface?.statevars);
|
||||
}, [iface?.id, iface?.statevars]);
|
||||
|
||||
// Activate panel logic for the live view; tear it down on unmount/panel switch.
|
||||
useEffect(() => {
|
||||
logicEngine.load(iface?.logic);
|
||||
return () => logicEngine.clear();
|
||||
}, [iface?.id, iface?.logic]);
|
||||
|
||||
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
@@ -54,6 +98,14 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
setCtxMenu(c => ({ ...c, visible: false }));
|
||||
}
|
||||
|
||||
function handleInfo() {
|
||||
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
|
||||
}
|
||||
|
||||
if (!iface) {
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
@@ -64,14 +116,50 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Plot panel: plots fill the viewport arranged in a recursive split layout.
|
||||
if (iface.kind === 'plot' && iface.layout) {
|
||||
const byId = new Map(iface.widgets.map(w => [w.id, w]));
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="plot-panel-view">
|
||||
<SplitLayout
|
||||
layout={iface.layout}
|
||||
renderLeaf={(wid) => {
|
||||
const widget = byId.get(wid);
|
||||
if (!widget) return <div class="plot-pane-empty">missing plot</div>;
|
||||
return (
|
||||
<PlotWidget
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
const inner = Comp
|
||||
? <Comp
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
onNavigate={onNavigate}
|
||||
@@ -79,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
title={`Unknown widget type: ${widget.type}`}
|
||||
@@ -88,12 +175,29 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<WidgetView
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
>
|
||||
{inner}
|
||||
</WidgetView>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAddSignals: (signals: SignalRef[]) => void;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
type: 'name' | 'tag' | 'property';
|
||||
key?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type SearchSource = 'cfs' | 'archiver';
|
||||
|
||||
export default function ChannelFinderModal({ onClose, onAddSignals }: Props) {
|
||||
const [source, setSource] = useState<SearchSource>('cfs');
|
||||
|
||||
// CFS State
|
||||
const [rules, setRules] = useState<Rule[]>([{ type: 'name', value: '' }]);
|
||||
const [cfsResults, setCfsResults] = useState<Array<{ name: string, properties: any, tags: any }>>([]);
|
||||
|
||||
// Archiver State
|
||||
const [archPattern, setArchPattern] = useState('');
|
||||
const [archResults, setArchResults] = useState<string[]>([]);
|
||||
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleCfsSearch() {
|
||||
if (!rules.some(r => r.value.trim() !== '')) return;
|
||||
setSearching(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const r of rules) {
|
||||
if (!r.value.trim()) continue;
|
||||
if (r.type === 'name') {
|
||||
params.append('~name', r.value.includes('*') ? r.value : `*${r.value}*`);
|
||||
} else if (r.type === 'tag') {
|
||||
params.append('~tag', r.value);
|
||||
} else if (r.type === 'property' && r.key) {
|
||||
params.append(r.key, r.value);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/channel-finder?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`CFS Search failed: ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
setCfsResults(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchSearch() {
|
||||
if (!archPattern.trim()) return;
|
||||
setSearching(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/archiver/search?q=${encodeURIComponent(archPattern)}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 501) throw new Error("Archiver discovery not configured");
|
||||
throw new Error(`Archiver Search failed: ${res.statusText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setArchResults(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
setRules([...rules, { type: 'property', key: '', value: '' }]);
|
||||
}
|
||||
|
||||
function updateRule(idx: number, patch: Partial<Rule>) {
|
||||
setRules(rules.map((r, i) => i === idx ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
function removeRule(idx: number) {
|
||||
setRules(rules.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function handleAddAll() {
|
||||
const refs = source === 'cfs'
|
||||
? cfsResults.map(r => ({ ds: 'epics', name: r.name }))
|
||||
: archResults.map(name => ({ ds: 'epics', name }));
|
||||
onAddSignals(refs);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const resultsCount = source === 'cfs' ? cfsResults.length : archResults.length;
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 750px; height: 85vh;">
|
||||
<div class="wizard-header">
|
||||
<span>PV Discovery</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body" style="display: flex; flex-direction: column;">
|
||||
{/* Source Tabs */}
|
||||
<div class="signal-tree-tabs" style="margin-bottom: 1.5rem;">
|
||||
<button
|
||||
class={`stab${source === 'cfs' ? ' stab-active' : ''}`}
|
||||
onClick={() => { setSource('cfs'); setError(''); }}
|
||||
>Channel Finder</button>
|
||||
<button
|
||||
class={`stab${source === 'archiver' ? ' stab-active' : ''}`}
|
||||
onClick={() => { setSource('archiver'); setError(''); }}
|
||||
>Archive Appliance</button>
|
||||
</div>
|
||||
|
||||
{source === 'cfs' ? (
|
||||
<div style="flex: 0 0 auto;">
|
||||
<div class="wizard-section-title">Channel Finder Rules</div>
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Filter by name, tags, or properties.</p>
|
||||
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<select
|
||||
class="prop-select"
|
||||
style="flex: 0 0 100px;"
|
||||
value={rule.type}
|
||||
onChange={e => updateRule(idx, { type: (e.target as HTMLSelectElement).value as any })}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="tag">Tag</option>
|
||||
<option value="property">Property</option>
|
||||
</select>
|
||||
|
||||
{rule.type === 'property' && (
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
placeholder="Property name"
|
||||
value={rule.key}
|
||||
onInput={e => updateRule(idx, { key: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 2;"
|
||||
placeholder={rule.type === 'name' ? 'PV name pattern' : 'Value'}
|
||||
value={rule.value}
|
||||
onInput={e => updateRule(idx, { value: (e.target as HTMLInputElement).value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCfsSearch()}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => removeRule(idx)}
|
||||
disabled={rules.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<button class="panel-btn" onClick={addRule}>+ Add Rule</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={handleCfsSearch}
|
||||
disabled={searching}
|
||||
style="padding: 0 1.5rem;"
|
||||
>
|
||||
{searching ? 'Searching…' : 'Search CFS'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style="flex: 0 0 auto;">
|
||||
<div class="wizard-section-title">Archiver Search</div>
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Search for archived PVs using glob patterns (e.g. <code>*TEMP*</code>).</p>
|
||||
|
||||
<div class="wizard-field wizard-field-row" style="gap: 0.5rem;">
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
autoFocus
|
||||
placeholder="PV pattern..."
|
||||
value={archPattern}
|
||||
onInput={e => setArchPattern((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleArchSearch()}
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={handleArchSearch}
|
||||
disabled={searching || !archPattern.trim()}
|
||||
style="padding: 0 1.5rem;"
|
||||
>
|
||||
{searching ? 'Searching…' : 'Search Archiver'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>Results {resultsCount > 0 && `(${resultsCount})`}</span>
|
||||
{resultsCount > 0 && (
|
||||
<button class="panel-btn" style="font-size: 0.7rem; padding: 0.1rem 0.4rem;" onClick={handleAddAll}>Add All to Sidebar</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
|
||||
<div class="panel-list" style="flex: 1; border: 1px solid #2d3748; border-radius: 4px; background: #0f1117; overflow-y: auto;">
|
||||
{resultsCount === 0 ? (
|
||||
<p class="hint" style="text-align: center; padding: 2rem;">
|
||||
{searching ? 'Searching…' : 'No results to show.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{source === 'cfs' ? (
|
||||
cfsResults.map(r => (
|
||||
<li key={r.name} class="iface-item" style="cursor: default;">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px;">
|
||||
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{r.name}</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
{r.tags?.map((t: any) => (
|
||||
<span key={t.name} style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155;">
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add to sidebar"
|
||||
onClick={() => onAddSignals([{ ds: 'epics', name: r.name }])}
|
||||
>+</button>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
archResults.map(name => (
|
||||
<li key={name} class="iface-item" style="cursor: default;">
|
||||
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{name}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add to sidebar"
|
||||
onClick={() => onAddSignals([{ ds: 'epics', name }])}
|
||||
>+</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-4
@@ -10,9 +10,10 @@ interface Props {
|
||||
y: number;
|
||||
signal: SignalRef | null;
|
||||
onClose: () => void;
|
||||
onInfo?: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose, onInfo }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -36,12 +37,15 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
if (!visible) return null;
|
||||
|
||||
function copySignalName() {
|
||||
if (signal) {
|
||||
navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
}
|
||||
if (signal) navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
onClose();
|
||||
}
|
||||
|
||||
function openInfo() {
|
||||
onClose();
|
||||
onInfo?.();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// Phase 5+: export CSV data
|
||||
onClose();
|
||||
@@ -62,6 +66,8 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={openInfo}>Signal info</button>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
@@ -20,6 +22,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import { checkExpr } from './lib/expr';
|
||||
|
||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||
|
||||
type CLNodeKind =
|
||||
| 'trigger.threshold'
|
||||
| 'trigger.change'
|
||||
| 'trigger.timer'
|
||||
| 'trigger.cron'
|
||||
| 'trigger.alarm'
|
||||
| 'gate.and'
|
||||
| 'flow.if'
|
||||
| 'flow.loop'
|
||||
| 'action.write'
|
||||
| 'action.delay'
|
||||
| 'action.log'
|
||||
| 'action.lua';
|
||||
|
||||
interface CLNode {
|
||||
id: string;
|
||||
kind: CLNodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
interface CLWire { from: string; fromPort?: string; to: string; }
|
||||
interface CLGraph {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
nodes: CLNode[];
|
||||
wires: CLWire[];
|
||||
}
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
|
||||
function splitRef(ref: string): { ds: string; name: string } {
|
||||
const i = ref.indexOf(':');
|
||||
if (i < 0) return { ds: '', name: '' };
|
||||
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
|
||||
}
|
||||
|
||||
// ── Geometry (shared with LogicEditor look/feel) ─────────────────────────────
|
||||
|
||||
const REM = (() => {
|
||||
if (typeof document === 'undefined') return 16;
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
})();
|
||||
const NODE_W = 11.5 * REM;
|
||||
const PORT_TOP = 2 * REM;
|
||||
const PORT_GAP = 1.375 * REM;
|
||||
const PORT_R = 0.375 * REM;
|
||||
|
||||
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
||||
|
||||
const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
|
||||
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
|
||||
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
|
||||
{ kind: 'trigger.cron', label: 'Cron', params: { spec: '*/5 * * * *' } },
|
||||
{ kind: 'trigger.alarm', label: 'Alarm', params: { signal: '', min: '0', max: '100' } },
|
||||
{ kind: 'gate.and', label: 'AND gate', params: {} },
|
||||
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
|
||||
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
|
||||
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
|
||||
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
|
||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
'trigger.threshold': 'Threshold',
|
||||
'trigger.change': 'On change',
|
||||
'trigger.timer': 'Timer',
|
||||
'trigger.cron': 'Cron',
|
||||
'trigger.alarm': 'Alarm',
|
||||
'gate.and': 'AND gate',
|
||||
'flow.if': 'If / else',
|
||||
'flow.loop': 'Loop',
|
||||
'action.write': 'Write',
|
||||
'action.delay': 'Delay',
|
||||
'action.log': 'Log',
|
||||
'action.lua': 'Lua script',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
|
||||
const CRON_PRESETS: { label: string; spec: string }[] = [
|
||||
{ label: 'Every minute', spec: '* * * * *' },
|
||||
{ label: 'Every 5 minutes', spec: '*/5 * * * *' },
|
||||
{ label: 'Every hour', spec: '0 * * * *' },
|
||||
{ label: 'Daily at 08:00', spec: '0 8 * * *' },
|
||||
{ label: 'Weekdays at 09:00', spec: '0 9 * * 1-5' },
|
||||
];
|
||||
|
||||
function isTrigger(kind: CLNodeKind): boolean { return kind.startsWith('trigger.'); }
|
||||
function category(kind: CLNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
|
||||
if (kind.startsWith('trigger.')) return 'trigger';
|
||||
if (kind.startsWith('gate.')) return 'gate';
|
||||
if (kind.startsWith('flow.')) return 'flow';
|
||||
return 'action';
|
||||
}
|
||||
function hasInput(kind: CLNodeKind): boolean { return !isTrigger(kind); }
|
||||
|
||||
interface Port { id: string; label: string; }
|
||||
function outputs(kind: CLNodeKind): Port[] {
|
||||
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
|
||||
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
|
||||
return [{ id: 'out', label: '' }];
|
||||
}
|
||||
function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).length * PORT_GAP; }
|
||||
|
||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||
|
||||
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function outAnchor(n: CLNode, port: string) {
|
||||
const ports = outputs(n.kind);
|
||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
||||
}
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
// ── Manager modal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
export default function ControlLogicEditor({ onClose }: Props) {
|
||||
const [graphs, setGraphs] = useState<CLGraph[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [graph, setGraph] = useState<CLGraph | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/controllogic');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setGraphs(await res.json());
|
||||
} catch (err) {
|
||||
setError(`Failed to load control logic: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
useEffect(() => { reload(); }, []);
|
||||
|
||||
function selectGraph(g: CLGraph) {
|
||||
if (dirty && !confirm('Discard unsaved changes to the current graph?')) return;
|
||||
setSelectedId(g.id);
|
||||
setGraph(JSON.parse(JSON.stringify(g)));
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function createGraph() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Omit<CLGraph, 'id'> = { name: 'New control logic', enabled: false, nodes: [], wires: [] };
|
||||
const res = await fetch('/api/v1/controllogic', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const created: CLGraph = await res.json();
|
||||
await reload();
|
||||
setSelectedId(created.id);
|
||||
setGraph(created);
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setError(`Create failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGraph() {
|
||||
if (!graph) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graph),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setDirty(false);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGraph(id: string) {
|
||||
if (!confirm('Delete this control logic graph? This stops it on the server.')) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
||||
if (selectedId === id) { setSelectedId(null); setGraph(null); setDirty(false); }
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Delete failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a graph's enabled flag straight from the list (persists immediately).
|
||||
async function toggleEnabled(g: CLGraph) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = { ...g, enabled: !g.enabled };
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(g.id)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (selectedId === g.id && graph) setGraph({ ...graph, enabled: updated.enabled });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Update failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function patchGraph(patch: Partial<CLGraph>) {
|
||||
if (!graph) return;
|
||||
setGraph({ ...graph, ...patch });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-modal">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Control logic</span>
|
||||
<span class="hint cl-subtitle">Server-side flows — run continuously, independent of any panel.</span>
|
||||
<div class="cl-header-actions">
|
||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
|
||||
<div class="cl-body">
|
||||
<div class="cl-list">
|
||||
<div class="cl-list-head">
|
||||
<span>Graphs</span>
|
||||
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
|
||||
</div>
|
||||
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
|
||||
{graphs.map(g => (
|
||||
<div key={g.id}
|
||||
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
|
||||
onClick={() => selectGraph(g)}>
|
||||
<span class={`cl-status-dot${g.enabled ? ' cl-status-on' : ''}`}
|
||||
title={g.enabled ? 'Enabled' : 'Disabled'} />
|
||||
<span class="cl-list-name" title={g.name}>{g.name || '(unnamed)'}</span>
|
||||
<button class="cl-mini-btn" title={g.enabled ? 'Disable' : 'Enable'}
|
||||
onClick={(e) => { e.stopPropagation(); toggleEnabled(g); }}>
|
||||
{g.enabled ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="cl-mini-btn" title="Delete"
|
||||
onClick={(e) => { e.stopPropagation(); deleteGraph(g.id); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="cl-editor-wrap">
|
||||
{!graph && <div class="cl-empty hint">Select a graph on the left, or create a new one.</div>}
|
||||
{graph && (
|
||||
<Fragment>
|
||||
<div class="cl-graph-bar">
|
||||
<input class="prop-input cl-name-input" value={graph.name}
|
||||
placeholder="Graph name"
|
||||
onInput={(e) => patchGraph({ name: (e.target as HTMLInputElement).value })} />
|
||||
<label class="cl-enable">
|
||||
<input type="checkbox" checked={graph.enabled}
|
||||
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||
</div>
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Node-graph editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function FlowEditor({ graph, onChange }: {
|
||||
graph: CLGraph;
|
||||
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
|
||||
}) {
|
||||
const nodes = graph.nodes;
|
||||
const wires = graph.wires;
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
|
||||
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
|
||||
const pendingRef = useRef(pendingWire);
|
||||
pendingRef.current = pendingWire;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Bare-name write targets are graph-local variables; offer them under 'local'.
|
||||
const localNames = Array.from(new Set(
|
||||
nodes.filter(n => n.kind === 'action.write')
|
||||
.map(n => n.params.target ?? '')
|
||||
.filter(t => t && !t.includes(':'))
|
||||
));
|
||||
const dsOptions = ['local', 'sys', ...dataSources];
|
||||
function signalOptions(ds: string): string[] {
|
||||
if (ds === 'local') return localNames;
|
||||
if (ds === 'sys') return ['time', 'dt'];
|
||||
return dsSignals[ds] ?? [];
|
||||
}
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change' || selected?.kind === 'trigger.alarm') {
|
||||
loadSignals(splitRef(selected.params.signal ?? '').ds);
|
||||
}
|
||||
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
||||
}, [selectedNode, selected?.kind]);
|
||||
|
||||
function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); }
|
||||
function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); }
|
||||
function setWires(next: CLWire[]) { emit({ nodes, wires: next }); }
|
||||
|
||||
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
||||
const node: CLNode = {
|
||||
id: genId(),
|
||||
kind: entry.kind,
|
||||
x: x ?? 40 + (nodes.length % 5) * 30,
|
||||
y: y ?? 40 + (nodes.length % 5) * 30,
|
||||
params: { ...entry.params },
|
||||
};
|
||||
emit({ nodes: [...nodes, node], wires });
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
}
|
||||
function patchParams(id: string, patch: Record<string, string>) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
||||
}
|
||||
function moveNode(id: string, x: number, y: number) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)));
|
||||
}
|
||||
function deleteNode(id: string) {
|
||||
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
|
||||
if (selectedNode === id) setSelectedNode(null);
|
||||
}
|
||||
function addWire(from: string, port: string, to: string) {
|
||||
if (from === to) return;
|
||||
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
|
||||
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
|
||||
}
|
||||
function deleteWire(idx: number) {
|
||||
setWires(wires.filter((_, i) => i !== idx));
|
||||
if (selectedWire === idx) setSelectedWire(null);
|
||||
}
|
||||
|
||||
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
||||
const el = canvasRef.current!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
|
||||
}
|
||||
|
||||
function startNodeDrag(e: MouseEvent, node: CLNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
window.addEventListener('mousemove', onNodeDragMove);
|
||||
window.addEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function onNodeDragMove(e: MouseEvent) {
|
||||
const d = dragNode.current;
|
||||
if (!d) return;
|
||||
const p = toCanvas(e);
|
||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy));
|
||||
}
|
||||
function onNodeDragUp() {
|
||||
dragNode.current = null;
|
||||
window.removeEventListener('mousemove', onNodeDragMove);
|
||||
window.removeEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
|
||||
function startWire(e: MouseEvent, node: CLNode, port: string) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
|
||||
window.addEventListener('mousemove', onWireMove);
|
||||
window.addEventListener('mouseup', onWireUp);
|
||||
}
|
||||
function onWireMove(e: MouseEvent) {
|
||||
const cur = pendingRef.current;
|
||||
if (!cur) return;
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ ...cur, x: p.x, y: p.y });
|
||||
}
|
||||
function endWire() {
|
||||
pendingRef.current = null;
|
||||
window.removeEventListener('mousemove', onWireMove);
|
||||
window.removeEventListener('mouseup', onWireUp);
|
||||
setPendingWire(null);
|
||||
}
|
||||
function onWireUp() { endWire(); }
|
||||
function finishWire(target: CLNode) {
|
||||
const cur = pendingRef.current;
|
||||
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
|
||||
endWire();
|
||||
}
|
||||
|
||||
const DRAG_MIME = 'application/x-uopi-cl-node';
|
||||
function onCanvasDragOver(e: DragEvent) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
function onCanvasDrop(e: DragEvent) {
|
||||
const kind = e.dataTransfer?.getData(DRAG_MIME);
|
||||
if (!kind) return;
|
||||
e.preventDefault();
|
||||
const entry = PALETTE.find(p => p.kind === kind);
|
||||
if (!entry) return;
|
||||
const p = toCanvas(e);
|
||||
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as Element;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selectedNode) deleteNode(selectedNode);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selectedNode, selectedWire, nodes, wires]);
|
||||
|
||||
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
function insertRef(id: string, field: string, ds: string, sig: string) {
|
||||
const cur = (byId.get(id)?.params[field]) ?? '';
|
||||
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flow-editor">
|
||||
<div class="flow-palette">
|
||||
<div class="flow-palette-title">Triggers</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Logic</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Actions</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
|
||||
<div class="flow-palette-hint hint">
|
||||
Triggers start a flow. Drag a node's right port to another node's left port to connect.
|
||||
In expressions, reference signals as <code>{'{ds:name}'}</code> and graph-local vars by name.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-canvas" ref={canvasRef}
|
||||
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}>
|
||||
<div class="flow-canvas-inner">
|
||||
<svg class="flow-wires">
|
||||
{wires.map((w, idx) => {
|
||||
const a = byId.get(w.from);
|
||||
const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a, w.fromPort ?? 'out');
|
||||
const p2 = inAnchor(b);
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
|
||||
);
|
||||
})}
|
||||
{pendingWire && (() => {
|
||||
const a = byId.get(pendingWire.from);
|
||||
if (!a) return null;
|
||||
const p1 = outAnchor(a, pendingWire.port);
|
||||
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{nodes.map(node => (
|
||||
<div key={node.id}
|
||||
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
||||
<button class="flow-node-del" title="Delete node"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||
</div>
|
||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||
|
||||
{hasInput(node.kind) && (
|
||||
<div class="flow-port flow-port-in" title="Input"
|
||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
||||
)}
|
||||
{outputs(node.kind).map((port, i) => (
|
||||
<Fragment key={port.id}>
|
||||
{port.label && (
|
||||
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
|
||||
)}
|
||||
<div class={`flow-port flow-port-out flow-port-${port.id}`}
|
||||
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
|
||||
title={`Output: ${port.id}`}
|
||||
onMouseDown={(e) => startWire(e, node, port.id)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-inspector">
|
||||
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
|
||||
{selected && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
|
||||
|
||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
|
||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={ds} options={dataSources}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
{selected.kind === 'trigger.threshold' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field" style="flex:0 0 4.5rem;">
|
||||
<label>Op</label>
|
||||
<select class="prop-select" value={selected.params.op ?? '>'}
|
||||
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
|
||||
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Value</label>
|
||||
<input class="prop-input" value={selected.params.value ?? ''}
|
||||
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === 'trigger.alarm' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Min</label>
|
||||
<input class="prop-input" type="number" value={selected.params.min ?? '0'}
|
||||
onInput={(e) => patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Max</label>
|
||||
<input class="prop-input" type="number" value={selected.params.max ?? '100'}
|
||||
onInput={(e) => patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
|
||||
{selected.kind === 'trigger.alarm' && <p class="hint">Fires when the signal leaves the [min, max] range (rising edge).</p>}
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'trigger.timer' && (
|
||||
<div class="wizard-field">
|
||||
<label>Interval (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
|
||||
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Fires repeatedly on the server while the graph is enabled.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'trigger.cron' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Schedule (cron)</label>
|
||||
<input class="prop-input" value={selected.params.spec ?? ''}
|
||||
placeholder="min hour dom month dow"
|
||||
onInput={(e) => patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Presets</label>
|
||||
<select class="prop-select" value=""
|
||||
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; if (v) patchParams(selected.id, { spec: v }); }}>
|
||||
<option value="">choose…</option>
|
||||
{CRON_PRESETS.map(p => <option key={p.spec} value={p.spec}>{p.label} ({p.spec})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">5 fields: minute hour day-of-month month day-of-week. Supports <code>*</code>, <code>*/n</code>, ranges <code>a-b</code> and lists <code>a,b</code>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'gate.and' && (
|
||||
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
|
||||
inputs are satisfied (level triggers like Threshold/Alarm use their current truth).</p>
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.if' && (
|
||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.loop' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Mode</label>
|
||||
<select class="prop-select" value={selected.params.mode ?? 'count'}
|
||||
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="count">Repeat N times</option>
|
||||
<option value="while">While condition</option>
|
||||
</select>
|
||||
</div>
|
||||
{(selected.params.mode ?? 'count') === 'count' ? (
|
||||
<div class="wizard-field">
|
||||
<label>Repeat count</label>
|
||||
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
|
||||
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
) : (
|
||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||
)}
|
||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.write' && (() => {
|
||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
<label>Delay (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
|
||||
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.log' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Label (optional)</label>
|
||||
<input class="prop-input" value={selected.params.label ?? ''}
|
||||
placeholder="prefix for the log line"
|
||||
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Logs this value to the server log — handy for debugging a flow." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.lua' && (
|
||||
<div class="wizard-field">
|
||||
<label>Lua script</label>
|
||||
<LuaEditor value={selected.params.script ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { script: v })} rows={14} />
|
||||
<p class="hint">
|
||||
Host functions: <code>get("ds:name")</code> reads a signal/sys/local value,
|
||||
<code>set("ds:name", v)</code> writes (bare name → graph-local var),
|
||||
<code>log(msg)</code> logs to the server. The <code>os/io</code> libraries are disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function paletteBtn(entry: PaletteEntry) {
|
||||
return (
|
||||
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
|
||||
title={`${entry.kind} — drag onto the canvas or click to add`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={() => addNode(entry)}>{entry.label}</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onInsert: (ds: string, sig: string) => void;
|
||||
dsOptions: string[];
|
||||
signalOptions: (ds: string) => string[];
|
||||
loadSignals: (ds: string) => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const [insDs, setInsDs] = useState('');
|
||||
const err = checkExpr(value);
|
||||
return (
|
||||
<div class="wizard-field">
|
||||
<label>{label}</label>
|
||||
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
|
||||
placeholder="expression"
|
||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||
{err && <p class="wizard-error">{err}</p>}
|
||||
<div class="flow-expr-insert">
|
||||
<SearchableSelect value={insDs} options={dsOptions}
|
||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
||||
onSelect={(sig) => onInsert(insDs, sig)}
|
||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
||||
</div>
|
||||
<p class="hint">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeSummary(n: CLNode): string {
|
||||
switch (n.kind) {
|
||||
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
|
||||
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
||||
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
|
||||
case 'trigger.cron': return n.params.spec || '(no schedule)';
|
||||
case 'trigger.alarm': return `${n.params.signal || '?'} ∉ [${n.params.min || '?'}, ${n.params.max || '?'}]`;
|
||||
case 'gate.and': return 'all inputs';
|
||||
case 'flow.if': return n.params.cond || '(no condition)';
|
||||
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
|
||||
? `repeat ${n.params.count || '0'}×`
|
||||
: `while ${n.params.cond || '?'}`;
|
||||
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
|
||||
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||
case 'action.lua': return 'Lua script';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
>
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
? <Comp key={widget.id} widget={widget} />
|
||||
@@ -376,8 +376,9 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`ov-${widget.id}`}
|
||||
|
||||
+483
-79
@@ -1,16 +1,29 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback, useRef } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||
import LogicEditor from './LogicEditor';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: () => void;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
@@ -68,12 +81,15 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W
|
||||
|
||||
const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'textlabel', label: 'Text Label' },
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -83,73 +99,223 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('edit');
|
||||
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [rightW, setRightW] = useState(260);
|
||||
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
|
||||
const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout');
|
||||
|
||||
// Undo / redo
|
||||
const undoStack = useRef<Widget[][]>([]);
|
||||
const redoStack = useRef<Widget[][]>([]);
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
const [canvasNonce, setCanvasNonce] = useState(0);
|
||||
|
||||
// Clipboard
|
||||
const clipboard = useRef<Widget[]>([]);
|
||||
|
||||
const startResize = useCallback((side: 'left' | 'right') => {
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = side === 'left' ? leftW : rightW;
|
||||
function onMove(mv: MouseEvent) {
|
||||
const dx = mv.clientX - startX;
|
||||
if (side === 'left') setLeftW(Math.max(140, startW + dx));
|
||||
else setRightW(Math.max(180, startW - dx));
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [leftW, rightW]);
|
||||
|
||||
const openHelp = useCallback((section = 'edit') => {
|
||||
setHelpSection(section);
|
||||
setShowHelp(true);
|
||||
}, []);
|
||||
|
||||
// Undo / redo. Snapshots capture both widgets and (for plot panels) the split
|
||||
// layout, so split/close/resize operations are undone atomically.
|
||||
type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
|
||||
const undoStack = useRef<Snapshot[]>([]);
|
||||
const redoStack = useRef<Snapshot[]>([]);
|
||||
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
|
||||
const widgetsRef = useRef(iface.widgets);
|
||||
widgetsRef.current = iface.widgets;
|
||||
const widgetsRef = useRef(iface.widgets || []);
|
||||
widgetsRef.current = iface.widgets || [];
|
||||
const layoutRef = useRef(iface.layout);
|
||||
layoutRef.current = iface.layout;
|
||||
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]];
|
||||
const snapshot = useCallback((): Snapshot => ({
|
||||
widgets: [...widgetsRef.current],
|
||||
layout: layoutRef.current,
|
||||
}), []);
|
||||
|
||||
const pushUndo = useCallback(() => {
|
||||
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(undoStack.current.length);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function undo() {
|
||||
const undo = useCallback(() => {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [widgetsRef.current, ...redoStack.current];
|
||||
redoStack.current = [snapshot(), ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: prev }));
|
||||
setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function redo() {
|
||||
const redo = useCallback(() => {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, widgetsRef.current];
|
||||
undoStack.current = [...undoStack.current, snapshot()];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: next }));
|
||||
setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
// Combined widgets + layout change for plot-panel split/close/resize.
|
||||
const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets, layout }));
|
||||
setDirty(true);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||
setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||
setIface(updated);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
|
||||
setIface(f => ({ ...f, statevars }));
|
||||
setDirty(true);
|
||||
initLocalState(statevars);
|
||||
}, []);
|
||||
|
||||
const handleLogicChange = useCallback((logic: LogicGraph) => {
|
||||
setIface(f => ({ ...f, logic }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Instantiate local state variables when the edited panel loads/changes so
|
||||
// they can be previewed live in the canvas just like real signals.
|
||||
useEffect(() => {
|
||||
initLocalState(iface.statevars);
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
// The logic editor manages its own undo/redo/clipboard/delete shortcuts.
|
||||
if (centerTab === 'logic') return;
|
||||
|
||||
// Plot panels use split/close controls instead of free-form widget editing;
|
||||
// only undo/redo apply here.
|
||||
if (iface.kind === 'plot') {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
const curWidgets = widgetsRef.current;
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
clipboard.current = curWidgets
|
||||
.filter(w => selectedIds.includes(w.id))
|
||||
.map(w => ({ ...w }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
|
||||
e.preventDefault();
|
||||
pushUndo();
|
||||
const offset = 20;
|
||||
const pasted = clipboard.current.map(w => ({
|
||||
...w,
|
||||
id: genWidgetId(),
|
||||
x: w.x + offset,
|
||||
y: w.y + offset,
|
||||
}));
|
||||
setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] }));
|
||||
setSelectedIds(pasted.map(w => w.id));
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(curWidgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(curWidgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// ── Align / distribute ─────────────────────────────────────────────────────
|
||||
|
||||
function doAlign(mode: string) {
|
||||
const aligned = alignWidgets(iface.widgets, selectedIds, mode);
|
||||
const doAlign = useCallback((mode: string) => {
|
||||
const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
|
||||
handleWidgetsChange(aligned);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
function doDistribute(axis: 'h' | 'v') {
|
||||
const distributed = distributeWidgets(iface.widgets, selectedIds, axis);
|
||||
const doDistribute = useCallback((axis: 'h' | 'v') => {
|
||||
const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
|
||||
handleWidgetsChange(distributed);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
// ── Insert static widget ───────────────────────────────────────────────────
|
||||
|
||||
function insertWidget(type: string) {
|
||||
const insertWidget = useCallback((type: string) => {
|
||||
setShowInsertMenu(false);
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
const newWidget: Widget = {
|
||||
@@ -160,22 +326,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
w: defW,
|
||||
h: defH,
|
||||
signals: [],
|
||||
options: type === 'textlabel' ? { label: 'Label' } : {},
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...iface.widgets, newWidget]);
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
setSelectedIds([newWidget.id]);
|
||||
}
|
||||
}, [iface.widgets, handleWidgetsChange]);
|
||||
|
||||
// ── Save / export / import ─────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const handleSave = useCallback(async (saveTag = '') => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
const base = iface.id
|
||||
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||
: '/api/v1/interfaces';
|
||||
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
|
||||
const method = iface.id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
@@ -188,14 +358,141 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setIface(f => ({ ...f, id: json.id }));
|
||||
}
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
// loadVersions is stable (declared below); intentionally omitted from deps.
|
||||
}, [iface, showHistory]);
|
||||
|
||||
function handleExport() {
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
const loadVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`Load version failed (${res.status})`);
|
||||
const restored = parseInterface(await res.text());
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an
|
||||
// already-persisted interface, save a new revision automatically. ──────────
|
||||
const saveRef = useRef(handleSave);
|
||||
saveRef.current = handleSave;
|
||||
const autoSaveState = useRef({ dirty, id: iface.id, saving });
|
||||
autoSaveState.current = { dirty, id: iface.id, saving };
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const s = autoSaveState.current;
|
||||
if (s.dirty && s.id && !s.saving) {
|
||||
saveRef.current('auto');
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const xml = serializeInterface(iface);
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -204,9 +501,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [iface]);
|
||||
|
||||
function handleImport() {
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
@@ -225,48 +522,30 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleLeave() {
|
||||
const handleLeave = useCallback(async () => {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
onDone();
|
||||
// Brand-new panel that was never saved: nothing to show — return to the
|
||||
// previously open panel (signalled by passing null).
|
||||
if (!iface.id) { onDone(null); return; }
|
||||
// Existing panel closed with unsaved edits: reopen the last saved (active)
|
||||
// version rather than carrying the discarded in-memory changes into view.
|
||||
if (dirty) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
onDone(parseInterface(await res.text()));
|
||||
} catch {
|
||||
onDone(null);
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(iface.widgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(iface.widgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}
|
||||
onDone(iface);
|
||||
}, [dirty, onDone, iface]);
|
||||
|
||||
const selectedWidget = selectedIds.length === 1
|
||||
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null
|
||||
? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
|
||||
: null;
|
||||
|
||||
const canUndo = historyLen > 0;
|
||||
@@ -274,7 +553,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const multiSelected = selectedIds.length > 1;
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
<div class="edit-mode-layout" tabIndex={-1}>
|
||||
{/* ── Toolbar ── */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -309,7 +588,8 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
|
||||
{/* Insert static widget */}
|
||||
{/* Insert static widget — not applicable to split plot panels */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
|
||||
{showInsertMenu && (
|
||||
@@ -320,6 +600,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Align/distribute — only when ≥2 selected */}
|
||||
{selectedIds.length >= 2 && (
|
||||
@@ -344,11 +625,25 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<ZoomControl />
|
||||
<ContextualHelp mode="edit" onOpenManual={openHelp} />
|
||||
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
|
||||
<button class="toolbar-btn" onClick={handleImport}>Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<button
|
||||
class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={toggleHistory}
|
||||
disabled={!iface.id}
|
||||
title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
|
||||
>
|
||||
🕘 History
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={() => handleSave(tag)}
|
||||
disabled={saving || !writable}
|
||||
title={writable ? '' : 'You have read-only access'}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="toolbar-btn" onClick={handleLeave}>✕ Close</button>
|
||||
@@ -357,21 +652,130 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
{/* ── Three-panel body ── */}
|
||||
<div class="edit-body">
|
||||
<SignalTree />
|
||||
{/* The logic tab is a self-contained flow editor with its own palette and
|
||||
inspector, so the layout-only side panes are hidden while it is open. */}
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<SignalTree
|
||||
width={leftW}
|
||||
panelId={iface.id}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
|
||||
</Fragment>
|
||||
)}
|
||||
<div class="edit-center">
|
||||
<div class="center-tabs">
|
||||
<button
|
||||
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('layout')}
|
||||
>Layout</button>
|
||||
{me.canEditLogic && (
|
||||
<button
|
||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('logic')}
|
||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||
)}
|
||||
</div>
|
||||
{centerTab === 'logic' && me.canEditLogic ? (
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
widgets={iface.widgets}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
) : iface.kind === 'plot' ? (
|
||||
<PlotPanelCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handlePlotChange}
|
||||
/>
|
||||
) : (
|
||||
<EditCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
width={rightW}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
{showHistory && (
|
||||
<div class="history-pane">
|
||||
<div class="history-pane-header">
|
||||
<span>Version History</span>
|
||||
<button class="icon-btn" onClick={() => setShowHistory(false)} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="history-save-row">
|
||||
<input
|
||||
class="history-tag-input"
|
||||
placeholder="Optional label…"
|
||||
value={tag}
|
||||
onInput={(e) => setTag((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { GroupNode, SignalRef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
}
|
||||
|
||||
// ── Pure tree helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
/** Insert a child node at the end of the folder at `path` (empty = root). */
|
||||
function insertAt(nodes: GroupNode[], path: number[], child: GroupNode): GroupNode[] {
|
||||
if (path.length === 0) return [...nodes, child];
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: insertAt(n.children, path.slice(1), child) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove the node at `path`. */
|
||||
function removeAt(nodes: GroupNode[], path: number[]): GroupNode[] {
|
||||
if (path.length === 1) return nodes.filter((_, i) => i !== path[0]);
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: removeAt(n.children, path.slice(1)) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the folder label at `path`. */
|
||||
function renameAt(nodes: GroupNode[], path: number[], label: string): GroupNode[] {
|
||||
if (path.length === 1) {
|
||||
return nodes.map((n, i) =>
|
||||
i === path[0] && n.kind === 'folder' ? { ...n, label } : n
|
||||
);
|
||||
}
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: renameAt(n.children, path.slice(1), label) };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Parse signal input ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse "ds:name" or bare "name" (defaults to epics). */
|
||||
function parseSignal(input: string): { ds: string; name: string } | null {
|
||||
const s = input.trim();
|
||||
if (!s) return null;
|
||||
const colon = s.indexOf(':');
|
||||
if (colon > 0) return { ds: s.slice(0, colon), name: s.slice(colon + 1) };
|
||||
return { ds: 'epics', name: s };
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GroupsTree({ onDragStart }: Props) {
|
||||
const [nodes, setNodes] = useState<GroupNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
// What the user is currently adding inline (null = nothing)
|
||||
const [addingAt, setAddingAt] = useState<{ path: number[]; kind: 'signal' | 'folder' } | null>(null);
|
||||
const [addInput, setAddInput] = useState('');
|
||||
|
||||
// Inline folder rename
|
||||
const [renamingPath, setRenamingPath] = useState<number[] | null>(null);
|
||||
const [renameInput, setRenameInput] = useState('');
|
||||
|
||||
// ── Load / save ─────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/groups')
|
||||
.then(r => r.json())
|
||||
.then((data: GroupNode[]) => { setNodes(data); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function save(next: GroupNode[]) {
|
||||
fetch('/api/v1/groups', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
}).catch(e => console.error('groups save failed:', e));
|
||||
}
|
||||
|
||||
function update(next: GroupNode[]) {
|
||||
setNodes(next);
|
||||
save(next);
|
||||
}
|
||||
|
||||
// ── Drag ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDraggable(ds: string, name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds, name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Inline add ──────────────────────────────────────────────────────────────
|
||||
|
||||
function startAdding(path: number[], kind: 'signal' | 'folder') {
|
||||
setAddingAt({ path, kind });
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (!addingAt) return;
|
||||
const { path, kind } = addingAt;
|
||||
const val = addInput.trim();
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
if (!val) return;
|
||||
if (kind === 'folder') {
|
||||
const node: GroupNode = { kind: 'folder', id: genId(), label: val, children: [] };
|
||||
update(insertAt(nodes, path, node));
|
||||
} else {
|
||||
const sig = parseSignal(val);
|
||||
if (!sig) return;
|
||||
const node: GroupNode = { kind: 'signal', ds: sig.ds, name: sig.name };
|
||||
update(insertAt(nodes, path, node));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
// ── Rename ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startRename(path: number[], label: string) {
|
||||
setRenamingPath(path);
|
||||
setRenameInput(label);
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (!renamingPath || !renameInput.trim()) { cancelRename(); return; }
|
||||
update(renameAt(nodes, renamingPath, renameInput.trim()));
|
||||
setRenamingPath(null);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenamingPath(null);
|
||||
setRenameInput('');
|
||||
}
|
||||
|
||||
// ── Collapse ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleCollapse(id: string) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderAddRow(path: number[], kind: 'signal' | 'folder') {
|
||||
const isActive = addingAt &&
|
||||
addingAt.kind === kind &&
|
||||
JSON.stringify(addingAt.path) === JSON.stringify(path);
|
||||
if (!isActive) return null;
|
||||
return (
|
||||
<div class="group-add-row">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder={kind === 'signal' ? 'ds:name or PV name…' : 'Folder name…'}
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: GroupNode, path: number[]) {
|
||||
const pathKey = JSON.stringify(path);
|
||||
|
||||
if (node.kind === 'signal') {
|
||||
return (
|
||||
<div
|
||||
key={pathKey}
|
||||
class="group-signal-item"
|
||||
title={`${node.ds}:${node.name}`}
|
||||
{...makeDraggable(node.ds, node.name)}
|
||||
>
|
||||
<span class="group-signal-ds">{node.ds}</span>
|
||||
<span class="group-signal-name">{node.name}</span>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Remove from group"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); update(removeAt(nodes, path)); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// folder node
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
const isRenaming = renamingPath !== null && JSON.stringify(renamingPath) === pathKey;
|
||||
|
||||
return (
|
||||
<div key={pathKey} class="group-folder">
|
||||
<div class="group-folder-header">
|
||||
<span
|
||||
class="group-folder-arrow"
|
||||
onClick={() => toggleCollapse(node.id)}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
class="group-rename-input"
|
||||
autoFocus
|
||||
value={renameInput}
|
||||
onInput={(e) => setRenameInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="group-folder-name"
|
||||
onDblClick={() => startRename(path, node.label)}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span class="group-folder-count">{node.children.length}</span>
|
||||
|
||||
<div class="group-folder-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add signal"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'signal'); }}
|
||||
>+📶</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add subfolder"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'folder'); }}
|
||||
>+📁</button>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Delete folder"
|
||||
onClick={() => update(removeAt(nodes, path))}
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div class="group-folder-children">
|
||||
{node.children.map((child, i) => renderNode(child, [...path, i]))}
|
||||
{renderAddRow(path, 'signal')}
|
||||
{renderAddRow(path, 'folder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAddingRoot = addingAt !== null && addingAt.path.length === 0;
|
||||
|
||||
return (
|
||||
<div class="groups-tree">
|
||||
{/* Root-level toolbar */}
|
||||
<div class="groups-toolbar">
|
||||
<button
|
||||
class="panel-btn"
|
||||
title="New root group"
|
||||
onClick={() => startAdding([], 'folder')}
|
||||
>
|
||||
+ Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline input for new root folder */}
|
||||
{isAddingRoot && addingAt?.kind === 'folder' && (
|
||||
<div class="group-add-row group-add-root">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder="Group name…"
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list group-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading groups…</p>
|
||||
) : nodes.length === 0 && !isAddingRoot ? (
|
||||
<p class="hint">No groups yet — click "+ Group" to create one.</p>
|
||||
) : (
|
||||
nodes.map((node, i) => renderNode(node, [i]))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+182
-6
@@ -406,7 +406,11 @@ const SECTIONS = [
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'plots', label: 'Plot Panels' },
|
||||
{ id: 'signals', label: 'Signals' },
|
||||
{ id: 'logic', label: 'Panel Logic' },
|
||||
{ id: 'control', label: 'Control Logic' },
|
||||
{ id: 'sharing', label: 'Sharing & Access' },
|
||||
{ id: 'history', label: 'Historical Data' },
|
||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
@@ -510,6 +514,24 @@ function SectionEdit() {
|
||||
<h3 class="help-h3">Align & distribute</h3>
|
||||
<p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p>
|
||||
|
||||
<h3 class="help-h3">Layout & Logic tabs</h3>
|
||||
<p>The centre of the editor has two tabs:</p>
|
||||
<ul class="help-list">
|
||||
<li><strong>Layout</strong> — the drag-and-drop widget canvas described above.</li>
|
||||
<li><strong>Logic</strong> — a visual flow editor that adds interactive behaviour to the
|
||||
panel (buttons that run actions, thresholds that pop up dialogs, timers, …). See the
|
||||
<em>Panel Logic</em> section. The tab is hidden if you are not permitted to edit logic.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Local variables</h3>
|
||||
<p>
|
||||
Panels can define their own <strong>local variables</strong> — lightweight values that live
|
||||
only inside the panel (no data source needed). Add them from the signal tree's
|
||||
<em>Local</em> group (or the Logic palette). They are written by Set-value widgets, buttons
|
||||
and logic actions, and referenced anywhere a signal is, making them handy for set-points,
|
||||
toggles and counters used by panel logic.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Saving</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Save</strong> — stores the panel on the server (XML). A <span style="color:#f59e0b">●</span> indicates unsaved changes.</li>
|
||||
@@ -520,6 +542,141 @@ function SectionEdit() {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionPlots() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
A <strong>plot panel</strong> is a special interface dedicated to charts. Instead of
|
||||
free-form boxes, plots <em>fill the viewport</em> and you split the space between them like
|
||||
panes in a tiling window manager (tmux / IDE style).
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Creating one</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click <strong>+ Plot</strong> in the interface list to create a plot panel — it opens
|
||||
with one full-viewport empty plot.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Splitting & arranging</h3>
|
||||
<ul class="help-list">
|
||||
<li>Hover a pane and use its split buttons (<strong>⬌</strong> vertical / <strong>⬍</strong>
|
||||
horizontal) to divide it — a new empty plot appears in the freed half.</li>
|
||||
<li>Drag the divider between two panes to resize them; nesting is unlimited.</li>
|
||||
<li>Click a pane to select it, then configure its plot in the Properties pane (plot type,
|
||||
time window, range, legend, per-signal colour).</li>
|
||||
<li>Drag a signal from the tree onto a pane to add it to that pane's plot.</li>
|
||||
<li>Use a pane's <strong>✕</strong> to remove it; the layout collapses onto its sibling.</li>
|
||||
</ul>
|
||||
<p>In view mode the saved split layout fills the screen with live, streaming plots.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLogic() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
The <strong>Logic</strong> tab in the panel editor is a node-graph (Node-RED style) flow
|
||||
editor. You wire <em>trigger</em> nodes to <em>action</em> nodes to give a panel interactive
|
||||
behaviour. Logic runs entirely client-side while the panel is open in view mode.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Building a flow</h3>
|
||||
<ul class="help-list">
|
||||
<li>Drag blocks from the left palette onto the canvas (or click to add), then drag from a
|
||||
node's output port to another node's input port to connect them.</li>
|
||||
<li>Select a node to edit its parameters in the right inspector.</li>
|
||||
<li>Expression fields accept arithmetic/logic over signals — reference a signal as
|
||||
<code>{'{ds:name}'}</code> and a panel-local variable by its bare name.</li>
|
||||
<li>The editor has its own undo/redo and copy/paste (see Keyboard Shortcuts).</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Node types</h3>
|
||||
<div class="help-widget-table">
|
||||
{[
|
||||
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
|
||||
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
|
||||
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
|
||||
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
|
||||
].map(w => (
|
||||
<div key={w.type} class="help-widget-row">
|
||||
<span class="help-widget-name">{w.type}</span>
|
||||
<span class="help-widget-desc">{w.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 class="help-h3">System helpers</h3>
|
||||
<ul class="help-list">
|
||||
<li><code>{'{sys:time}'}</code> — current time in epoch seconds.</li>
|
||||
<li><code>{'{sys:dt}'}</code> — seconds since the firing trigger last fired (useful for rates).</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionControl() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
<strong>Control logic</strong> is server-side automation. Unlike panel logic (which only
|
||||
runs while a panel is open in a browser), control-logic graphs run continuously on the
|
||||
server, independent of any client. Open them with the <strong>⚙ Control logic</strong>
|
||||
button in the view-mode toolbar.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">What it can do</h3>
|
||||
<ul class="help-list">
|
||||
<li>React to <em>cron</em> schedules and signal <em>alarm</em>/threshold conditions.</li>
|
||||
<li>Run a <strong>Lua</strong> block for custom logic and compute writes back to signals.</li>
|
||||
<li>Each graph can be enabled/disabled independently; changes reload the engine live.</li>
|
||||
</ul>
|
||||
|
||||
<p class="help-callout">
|
||||
Editing both panel logic and control logic can be restricted to an allowlist of users or
|
||||
groups (<code>server.logic_editors</code> in the config). When restricted, users not on the
|
||||
list keep full access to everything else but cannot add or change logic — the Logic tab and
|
||||
the ⚙ Control logic button are hidden for them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionSharing() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
uopi has graduated, server-enforced access control. Identity comes from a trusted
|
||||
reverse-proxy header (with a configurable default for unproxied/LAN use) — there is no login
|
||||
page in the app itself.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Global access levels</h3>
|
||||
<ul class="help-list">
|
||||
<li>Every user is trusted with full <strong>write</strong> access by default.</li>
|
||||
<li>A config blacklist can downgrade specific users to <strong>read-only</strong> or
|
||||
<strong>no access</strong>. Read-only users see panels but cannot edit or write signals.</li>
|
||||
<li>Named <strong>groups</strong> are defined in the config and used by panel sharing.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Panel ownership & sharing</h3>
|
||||
<ul class="help-list">
|
||||
<li>New panels are <strong>private</strong> to their owner by default.</li>
|
||||
<li>Use the <strong>Share</strong> button on a panel (in the interface list) to grant
|
||||
read or write to specific users/groups, or make it public.</li>
|
||||
<li>Your global level always caps the per-panel permission (read-only stays read-only).</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Folders</h3>
|
||||
<ul class="help-list">
|
||||
<li>Organise panels into nested <strong>folders</strong> in the interface list.</li>
|
||||
<li>Permissions inherit down the folder chain.</li>
|
||||
<li>Drag panels to reorder them or move them between folders.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWidgets() {
|
||||
return (
|
||||
<div>
|
||||
@@ -602,7 +759,19 @@ function SectionSignals() {
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Synthetic signals</h3>
|
||||
<p>Click <strong>+ Synthetic</strong> to open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, …), and optional metadata. The new signal appears in the tree immediately.</p>
|
||||
<p>Click <strong>+ Synthetic</strong> to compose a new signal from existing ones. A quick
|
||||
<em>wizard</em> covers the common case (pick an input + a processing node such as gain,
|
||||
offset, moving average, lowpass/highpass, derivative, integral, clamp or a custom
|
||||
formula). For multi-input pipelines, the <strong>node-graph editor</strong> lets you wire
|
||||
inputs through a chain of DSP blocks visually. The new signal appears in the tree
|
||||
immediately.</p>
|
||||
<p>Each synthetic signal has a <strong>visibility scope</strong>: <em>panel</em> (only the
|
||||
panel that created it), <em>user</em>, or <em>global</em> (shared with everyone).</p>
|
||||
|
||||
<h3 class="help-h3">Local variables</h3>
|
||||
<p>The <strong>Local</strong> group holds panel-local variables — values stored in the
|
||||
panel itself with no data source. Use them for set-points, toggles and counters driven by
|
||||
panel logic. They are added in the editor and saved with the panel.</p>
|
||||
</div>
|
||||
<DiagramSignalTree />
|
||||
</div>
|
||||
@@ -643,6 +812,8 @@ function SectionHistory() {
|
||||
|
||||
function SectionShortcuts() {
|
||||
const shortcuts: Array<[string, string]> = [
|
||||
['Ctrl+C', 'Copy selected widgets (Edit mode)'],
|
||||
['Ctrl+V', 'Paste widgets from clipboard (Edit mode)'],
|
||||
['Ctrl+Z', 'Undo last change (Edit mode)'],
|
||||
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
|
||||
['Ctrl+A', 'Select all widgets (Edit mode)'],
|
||||
@@ -674,14 +845,15 @@ function SectionShortcuts() {
|
||||
|
||||
<h3 class="help-h3">API & Metrics</h3>
|
||||
<ul class="help-list">
|
||||
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, …</li>
|
||||
<li>Prometheus metrics: <code>/metrics</code></li>
|
||||
<li>Health check: <code>/healthz</code></li>
|
||||
<li>WebSocket: <code>ws://<host>/ws</code></li>
|
||||
<li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
|
||||
<li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
|
||||
<li>Identity & groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</code></li>
|
||||
<li>Control logic: <code>/api/v1/controllogic</code></li>
|
||||
<li>Prometheus metrics: <code>/metrics</code> · Health check: <code>/healthz</code> · WebSocket: <code>ws://<host>/ws</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Configuration file</h3>
|
||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./interfaces"{'\n'}# optional: restrict who may edit panel/control logic{'\n'}# logic_editors = ["operators"]{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -691,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
plots: SectionPlots,
|
||||
signals: SectionSignals,
|
||||
logic: SectionLogic,
|
||||
control: SectionControl,
|
||||
sharing: SectionSharing,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { getMetaStore, getSignalStore } from './lib/stores';
|
||||
import type { SignalRef, SignalMeta, SignalValue } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
signal: SignalRef;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v: any): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
if (Array.isArray(v)) return `[${v.slice(0, 8).map((x: any) => fmt(x)).join(', ')}${v.length > 8 ? ', …' : ''}]`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fmtTs(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
|
||||
export default function InfoPanel({ signal, onClose }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [sv, setSv] = useState<SignalValue>({ value: null, quality: 'unknown', ts: null });
|
||||
|
||||
useEffect(() => {
|
||||
const unsubM = getMetaStore(signal).subscribe(setMeta);
|
||||
const unsubV = getSignalStore(signal).subscribe(setSv);
|
||||
return () => { unsubM(); unsubV(); };
|
||||
}, [signal.ds, signal.name]);
|
||||
|
||||
return (
|
||||
<div class="info-panel">
|
||||
<div class="info-panel-header">
|
||||
<span class="info-panel-title">Signal Info</span>
|
||||
<button class="info-panel-close" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="info-panel-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Datasource</td><td class="info-val info-mono">{signal.ds}</td></tr>
|
||||
<tr><td class="info-key">Name</td><td class="info-val info-mono">{signal.name}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-sep" />
|
||||
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="info-key">Value</td>
|
||||
<td class="info-val">
|
||||
<span
|
||||
class="quality-dot"
|
||||
style={`background:${qualityColor(sv.quality)};display:inline-block;margin-right:5px;vertical-align:middle;`}
|
||||
/>
|
||||
{fmt(sv.value)}{meta?.unit ? ` ${meta.unit}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td class="info-key">Quality</td><td class="info-val">{sv.quality}</td></tr>
|
||||
<tr><td class="info-key">Timestamp</td><td class="info-val">{fmtTs(sv.ts)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Type</td><td class="info-val">{meta.type}</td></tr>
|
||||
{meta.unit && <tr><td class="info-key">Unit</td><td class="info-val">{meta.unit}</td></tr>}
|
||||
<tr><td class="info-key">Writable</td><td class="info-val">{meta.writable ? 'Yes' : 'No'}</td></tr>
|
||||
{(meta.displayLow !== 0 || meta.displayHigh !== 0) && (
|
||||
<tr>
|
||||
<td class="info-key">Display range</td>
|
||||
<td class="info-val">{meta.displayLow} … {meta.displayHigh}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta.tags && meta.tags.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Tags</div>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; padding: 4px 0.75rem;">
|
||||
{meta.tags.map(t => (
|
||||
<span key={t} style="font-size: 0.7rem; background: #1e293b; color: #94a3b8; padding: 1px 6px; border-radius: 4px; border: 1px solid #334155;">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.properties && Object.keys(meta.properties).length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Properties</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{Object.entries(meta.properties).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td class="info-key">{k}</td>
|
||||
<td class="info-val">{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">States</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{meta.enumStrings.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td class="info-key">{i}</td>
|
||||
<td class="info-val info-mono">{s}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.description && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Description</div>
|
||||
<div class="info-desc">{meta.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+206
-32
@@ -1,36 +1,110 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
interface ServerInterface {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
}
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface, InterfaceListItem, Folder } from './lib/types';
|
||||
import ShareDialog from './ShareDialog';
|
||||
|
||||
interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => void;
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
onNewPlot?: () => void;
|
||||
onEditId?: (id: string) => void;
|
||||
width?: number;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
|
||||
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragId = useRef<string | null>(null);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
// Panels in a folder ('' = root), sorted by their stored order then name.
|
||||
function panelsIn(folder: string): InterfaceListItem[] {
|
||||
return interfaces
|
||||
.filter(i => (i.folder || '') === folder)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
|
||||
}
|
||||
|
||||
// Persist a new ordering for a folder's panels (also moves panels between folders).
|
||||
async function reorder(folder: string, ids: string[]) {
|
||||
const res = await fetch('/api/v1/interfaces/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder, ids }),
|
||||
});
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragId.current = null;
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
// Drop dragged panel just before `target` within target's folder.
|
||||
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = dragId.current;
|
||||
if (!id || id === target.id) { endDrag(); return; }
|
||||
const dest = target.folder || '';
|
||||
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
|
||||
const idx = ids.indexOf(target.id);
|
||||
ids.splice(idx < 0 ? ids.length : idx, 0, id);
|
||||
reorder(dest, ids);
|
||||
endDrag();
|
||||
}
|
||||
|
||||
// Drop dragged panel at the end of `folder` ('' = root).
|
||||
function dropInFolder(folder: string, e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = dragId.current;
|
||||
if (!id) { endDrag(); return; }
|
||||
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
|
||||
ids.push(id);
|
||||
reorder(folder, ids);
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/interfaces')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setInterfaces(data))
|
||||
Promise.all([
|
||||
fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
|
||||
fetch('/api/v1/folders').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([ifaceData, folderData]) => {
|
||||
const list = Array.isArray(ifaceData) ? ifaceData : [];
|
||||
const normalized: InterfaceListItem[] = list.map((item: any) => ({
|
||||
id: String(item.id || item.ID || ''),
|
||||
name: String(item.name || item.Name || ''),
|
||||
version: Number(item.version || item.Version || 0),
|
||||
owner: item.owner ? String(item.owner) : '',
|
||||
folder: item.folder ? String(item.folder) : '',
|
||||
order: Number(item.order || 0),
|
||||
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
||||
})).filter((item: InterfaceListItem) => item.id);
|
||||
setInterfaces(normalized);
|
||||
setFolders(Array.isArray(folderData) ? folderData : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const handleRefresh = () => refresh();
|
||||
window.addEventListener('uopi:refresh-interfaces', handleRefresh);
|
||||
return () => window.removeEventListener('uopi:refresh-interfaces', handleRefresh);
|
||||
}, []);
|
||||
|
||||
function triggerImport() {
|
||||
fileInputRef.current?.click();
|
||||
@@ -65,13 +139,105 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
|
||||
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
|
||||
}
|
||||
|
||||
async function handleNewFolder(parent: string) {
|
||||
const name = prompt('New folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const res = await fetch('/api/v1/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), parent }),
|
||||
});
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(id: string, name: string) {
|
||||
if (!confirm(`Delete folder "${name}"? Its panels and subfolders move up one level.`)) return;
|
||||
const res = await fetch(`/api/v1/folders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
// The caller may manage sharing when they can write and either own the panel
|
||||
// or it is unmanaged (no owner recorded yet).
|
||||
function canShare(item: InterfaceListItem): boolean {
|
||||
return writable && (!item.owner || item.owner === me.user);
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
function renderPanel(item: InterfaceListItem) {
|
||||
const drag = writable && item.perm === 'write';
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<li
|
||||
key={item.id}
|
||||
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
|
||||
draggable={drag}
|
||||
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
|
||||
onDragEnd={endDrag}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
|
||||
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
|
||||
onDrop={(e) => dropOnPanel(item, e)}
|
||||
>
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
|
||||
{item.name || item.id}
|
||||
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}>✎</button>}
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}>⛶</button>
|
||||
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}>⎘</button>}
|
||||
{canShare(item) && <button class="icon-btn" title="Share" onClick={() => setShare({ id: item.id, name: item.name })}>⚹</button>}
|
||||
{item.perm === 'write' && <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(item.id, item.name)}>✕</button>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a folder and everything beneath it (subfolders first, then panels).
|
||||
function renderFolder(folder: Folder) {
|
||||
const children = folders.filter(f => f.parent === folder.id);
|
||||
const panels = panelsIn(folder.id);
|
||||
const open = expanded[folder.id] !== false; // default expanded
|
||||
const canDrop = folder.perm === 'write';
|
||||
return (
|
||||
<li key={folder.id} class="iface-folder">
|
||||
<div
|
||||
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
|
||||
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
|
||||
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
|
||||
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
|
||||
>
|
||||
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
|
||||
{open ? '▾' : '▸'} {folder.name}
|
||||
</span>
|
||||
{folder.perm === 'write' && (
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="New subfolder" onClick={() => handleNewFolder(folder.id)}>+</button>
|
||||
<button class="icon-btn iface-delete" title="Delete folder" onClick={() => handleDeleteFolder(folder.id, folder.name)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<ul class="iface-list iface-sublist">
|
||||
{children.map(renderFolder)}
|
||||
{panels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parent);
|
||||
const rootPanels = panelsIn('');
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Interfaces</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
@@ -79,9 +245,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div class="panel-body">
|
||||
{writable && (
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
|
||||
<button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
@@ -91,32 +260,37 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : interfaces.length === 0 ? (
|
||||
) : interfaces.length === 0 && folders.length === 0 ? (
|
||||
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{interfaces.map(iface => (
|
||||
<li key={iface.id} class="iface-item">
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}>✎</button>
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}>⛶</button>
|
||||
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<ul
|
||||
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
|
||||
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
|
||||
onDrop={(e) => dropInFolder('', e)}
|
||||
>
|
||||
{rootFolders.map(renderFolder)}
|
||||
{rootPanels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share && (
|
||||
<ShareDialog
|
||||
ifaceId={share.id}
|
||||
ifaceName={share.name}
|
||||
folders={folders}
|
||||
onClose={() => setShare(null)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { logicDialogs, type DialogRequest } from './lib/logic';
|
||||
|
||||
// Renders the user-interaction dialogs requested by action.dialog.* logic nodes.
|
||||
// The engine pushes requests to the `logicDialogs` store and awaits the user's
|
||||
// response; this component shows each as a modal and calls req.resolve.
|
||||
export default function LogicDialogs() {
|
||||
const [reqs, setReqs] = useState<DialogRequest[]>([]);
|
||||
useEffect(() => logicDialogs.subscribe(setReqs), []);
|
||||
if (reqs.length === 0) return null;
|
||||
return <Fragment>{reqs.map(r => <DialogModal key={r.id} req={r} />)}</Fragment>;
|
||||
}
|
||||
|
||||
function DialogModal({ req }: { req: DialogRequest; key?: string }) {
|
||||
const isSetpoint = req.kind === 'setpoint';
|
||||
const inputIdx = req.fields
|
||||
.map((f, i) => (f.type === 'input' ? i : -1))
|
||||
.filter(i => i >= 0);
|
||||
// Per-input editable values, keyed by field index, seeded from the prefill.
|
||||
const [vals, setVals] = useState<Record<number, string>>(
|
||||
() => Object.fromEntries(inputIdx.map(i => [i, req.fields[i].value]))
|
||||
);
|
||||
|
||||
function ok() {
|
||||
req.resolve(inputIdx.map(i => vals[i] ?? ''));
|
||||
}
|
||||
function cancel() { req.resolve(null); }
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !(e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); ok(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
|
||||
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
|
||||
<div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
|
||||
{req.message && <div class="logic-dialog-message">{req.message}</div>}
|
||||
{req.fields.map((f, i) => (
|
||||
<div class="logic-dialog-field" key={i}>
|
||||
{f.label && <label class="logic-dialog-label">{f.label}</label>}
|
||||
{f.type === 'input' ? (
|
||||
<input class="prop-input logic-dialog-input" type="number"
|
||||
autoFocus={i === inputIdx[0]}
|
||||
value={vals[i] ?? ''}
|
||||
onInput={(e) => setVals(v => ({ ...v, [i]: (e.target as HTMLInputElement).value }))}
|
||||
onKeyDown={onKey} />
|
||||
) : (
|
||||
<span class="logic-dialog-value">{f.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div class="logic-dialog-actions">
|
||||
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
|
||||
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type TT = 'kw' | 'str' | 'cmt' | 'num' | 'text';
|
||||
|
||||
const LUA_KEYWORDS = new Set([
|
||||
'if', 'then', 'else', 'elseif', 'end', 'while', 'do', 'for', 'in',
|
||||
'return', 'local', 'function', 'not', 'and', 'or', 'nil', 'true',
|
||||
'false', 'break', 'repeat', 'until',
|
||||
]);
|
||||
|
||||
interface Tok { t: TT; s: string; }
|
||||
|
||||
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
|
||||
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
|
||||
function isDigit(c: string) { return c >= '0' && c <= '9'; }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
|
||||
// Single-line comment
|
||||
if (c === '-' && src[i + 1] === '-') {
|
||||
const end = src.indexOf('\n', i);
|
||||
const stop = end < 0 ? src.length : end;
|
||||
out.push({ t: 'cmt', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// String: "..." or '...'
|
||||
if (c === '"' || c === "'") {
|
||||
let j = i + 1;
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue; }
|
||||
if (src[j] === c) { j++; break; }
|
||||
j++;
|
||||
}
|
||||
out.push({ t: 'str', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Long string [[...]]
|
||||
if (c === '[' && src[i + 1] === '[') {
|
||||
const end = src.indexOf(']]', i + 2);
|
||||
const stop = end < 0 ? src.length : end + 2;
|
||||
out.push({ t: 'str', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number
|
||||
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
|
||||
let j = i;
|
||||
if (src[j] === '0' && (src[j + 1] === 'x' || src[j + 1] === 'X')) {
|
||||
j += 2;
|
||||
while (j < src.length && /[0-9a-fA-F]/.test(src[j])) j++;
|
||||
} else {
|
||||
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
|
||||
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
|
||||
j++;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
while (j < src.length && isDigit(src[j])) j++;
|
||||
}
|
||||
}
|
||||
out.push({ t: 'num', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword
|
||||
if (isAlpha(c)) {
|
||||
let j = i;
|
||||
while (j < src.length && isAlNum(src[j])) j++;
|
||||
const word = src.slice(i, j);
|
||||
out.push({ t: LUA_KEYWORDS.has(word) ? 'kw' : 'text', s: word });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ t: 'text', s: c });
|
||||
i++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTokens(src: string) {
|
||||
return tokenize(src).map((tok, idx) =>
|
||||
tok.t === 'text'
|
||||
? <span key={idx}>{tok.s}</span>
|
||||
: <span key={idx} class={`lua-${tok.t}`}>{tok.s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export default function LuaEditor({ value, onChange, rows = 12 }: Props) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = ta.scrollTop;
|
||||
preRef.current.scrollLeft = ta.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="lua-editor" style={`height:${rows * 1.5}em;`}>
|
||||
<pre ref={preRef} class="lua-pre" aria-hidden="true">
|
||||
{renderTokens(value)}
|
||||
{'\n'}
|
||||
</pre>
|
||||
<textarea
|
||||
class="lua-textarea"
|
||||
value={value}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
||||
onScroll={syncScroll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, SignalRef, PlotLayout } from './lib/types';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import { genWidgetId } from './EditCanvas';
|
||||
import { splitLeaf, removeLeaf, setRatioAtPath, countLeaves } from './lib/plotLayout';
|
||||
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
/** Apply a combined widgets + layout change as one undoable step. */
|
||||
onChange: (widgets: Widget[], layout: PlotLayout) => void;
|
||||
}
|
||||
|
||||
function newPlotWidget(): Widget {
|
||||
return {
|
||||
id: genWidgetId(),
|
||||
type: 'plot',
|
||||
x: 0, y: 0, w: 800, h: 500,
|
||||
signals: [],
|
||||
options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' },
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlotPanelCanvas({ iface, selectedIds, onSelect, onChange }: Props) {
|
||||
const layout = iface.layout ?? { type: 'leaf', widget: iface.widgets[0]?.id ?? '' };
|
||||
const byId = new Map(iface.widgets.map(w => [w.id, w]));
|
||||
const single = countLeaves(layout) <= 1;
|
||||
|
||||
// When there is only one pane, it is always the selected one. Also recover
|
||||
// from a selection that points at a widget no longer present in this panel.
|
||||
useEffect(() => {
|
||||
const onlyId = single ? (layout.type === 'leaf' ? layout.widget : iface.widgets[0]?.id) : null;
|
||||
if (onlyId && selectedIds[0] !== onlyId) {
|
||||
onSelect([onlyId]);
|
||||
} else if (!single && selectedIds.length === 1 && !byId.has(selectedIds[0])) {
|
||||
onSelect([]);
|
||||
}
|
||||
}, [single, layout, selectedIds.join(',')]);
|
||||
|
||||
function handleSplit(widgetId: string, dir: 'h' | 'v') {
|
||||
const w = newPlotWidget();
|
||||
const nextLayout = splitLeaf(layout, widgetId, dir, w.id);
|
||||
onChange([...iface.widgets, w], nextLayout);
|
||||
onSelect([w.id]);
|
||||
}
|
||||
|
||||
function handleClose(widgetId: string) {
|
||||
if (single) return;
|
||||
const nextLayout = removeLeaf(layout, widgetId);
|
||||
onChange(iface.widgets.filter(w => w.id !== widgetId), nextLayout);
|
||||
onSelect([]);
|
||||
}
|
||||
|
||||
function handleResize(path: number[], ratio: number) {
|
||||
onChange(iface.widgets, setRatioAtPath(layout, path, ratio));
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const json = e.dataTransfer?.getData('application/json');
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
if (widget.signals.some(s => s.ds === sig.ds && s.name === sig.name)) return;
|
||||
const updated = { ...widget, signals: [...widget.signals, sig] };
|
||||
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w), layout);
|
||||
onSelect([updated.id]);
|
||||
}
|
||||
|
||||
function renderLeaf(widgetId: string) {
|
||||
const widget = byId.get(widgetId);
|
||||
const selected = selectedIds.includes(widgetId);
|
||||
return (
|
||||
<div
|
||||
class={`plot-pane${selected ? ' plot-pane-selected' : ''}`}
|
||||
onMouseDownCapture={() => onSelect([widgetId])}
|
||||
onDragOver={(e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }}
|
||||
onDrop={(e: DragEvent) => widget && handleDrop(e, widget)}
|
||||
>
|
||||
{selected && <div class="plot-pane-badge">editing</div>}
|
||||
{widget
|
||||
? <PlotWidget widget={widget} timeRange={null} />
|
||||
: <div class="plot-pane-empty">missing plot</div>}
|
||||
|
||||
<div class="plot-pane-overlay" onMouseDown={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<button class="plot-pane-btn" title="Split left/right" onClick={() => handleSplit(widgetId, 'h')}>⬌</button>
|
||||
<button class="plot-pane-btn" title="Split top/bottom" onClick={() => handleSplit(widgetId, 'v')}>⬍</button>
|
||||
<button class="plot-pane-btn" title="Remove this plot" disabled={single} onClick={() => handleClose(widgetId)}>✕</button>
|
||||
</div>
|
||||
|
||||
{widget && widget.signals.length === 0 && (
|
||||
<div class="plot-pane-hint">Drag a signal here, then configure it in the properties pane →</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="plot-panel-edit">
|
||||
<SplitLayout layout={layout} renderLeaf={renderLeaf} onResize={handleResize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+70
-20
@@ -1,5 +1,5 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
iface: Interface;
|
||||
onChange: (updated: Widget) => void;
|
||||
onIfaceChange: (updated: Interface) => void;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: any }) {
|
||||
@@ -20,29 +21,39 @@ function Field({ label, children }: { label: string; children: any }) {
|
||||
}
|
||||
|
||||
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [local, setLocal] = useState(value);
|
||||
const [local, setLocal] = useState(value || '');
|
||||
|
||||
// Sync when external value changes (e.g. different widget selected)
|
||||
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
|
||||
setLocal(value);
|
||||
}
|
||||
useEffect(() => {
|
||||
setLocal(value || '');
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
class="prop-input"
|
||||
value={local}
|
||||
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCommit((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Widgets that show units from signal metadata (can be overridden)
|
||||
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
||||
// Widgets where the user can set a numeric format string
|
||||
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
||||
// Widgets with a signal-based label (can be overridden)
|
||||
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
||||
// Widgets with multiple signals
|
||||
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
function setOpt(key: string, value: string) {
|
||||
@@ -64,7 +75,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
const w = selected;
|
||||
|
||||
return (
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Properties</span>}
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
@@ -76,13 +87,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<div class="props-body">
|
||||
{/* Canvas-level properties */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
|
||||
<Field label="Name">
|
||||
<TextInput
|
||||
value={iface.name}
|
||||
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
||||
/>
|
||||
</Field>
|
||||
{/* Plot panels fill the viewport via the split layout — fixed px size
|
||||
is not meaningful, so width/height are hidden. */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
@@ -102,9 +117,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{w && (
|
||||
<div class="props-section">
|
||||
<div class="props-section" key={w.id}>
|
||||
<div class="props-section-title">{w.type}</div>
|
||||
|
||||
{/* Position / size */}
|
||||
@@ -154,24 +171,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
signal widgets use it as header label */}
|
||||
{w.type !== 'image' && w.type !== 'link' && (
|
||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||
<TextInput
|
||||
value={w.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
{LABEL_WIDGETS.has(w.type) && (
|
||||
{LABEL_WIDGETS.has(w.type) ? (
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Unit override for signal widgets */}
|
||||
{UNIT_WIDGETS.has(w.type) && (
|
||||
<Field label="Unit override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
<Field label="Unit">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
|
||||
<span class="prop-hint">Empty = from meta · "none" = hide</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Format string for numeric display */}
|
||||
{FORMAT_WIDGETS.has(w.type) && (
|
||||
<Field label="Format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -240,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<div class="prop-field-col">
|
||||
<select
|
||||
class="prop-input"
|
||||
value={w.options['action'] ?? ''}
|
||||
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{(iface.logic?.nodes ?? [])
|
||||
.filter(n => n.kind === 'trigger.button')
|
||||
.map(n => (
|
||||
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -271,6 +315,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<Field label="Y max">
|
||||
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
||||
</Field>
|
||||
<Field label="Y format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Legend">
|
||||
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
||||
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
|
||||
// A dropdown with an inline search box. Extracted from SyntheticWizard so the
|
||||
// same searchable picker can be reused (e.g. the logic editor's signal field).
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string;
|
||||
options: string[];
|
||||
onSelect: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(o => o.toLowerCase().includes(f));
|
||||
}, [options, filter]);
|
||||
|
||||
return (
|
||||
<div class="search-select">
|
||||
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
||||
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div class="search-select-dropdown">
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="Search…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="search-select-list">
|
||||
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||
{filtered.map(opt => (
|
||||
<div
|
||||
key={opt}
|
||||
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { PanelACL, Grant, Folder } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
ifaceId: string;
|
||||
ifaceName: string;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ShareDialog edits a single panel's sharing settings: its folder, public
|
||||
// visibility, and explicit per-user / per-group grants. Only the owner can
|
||||
// reach it (the caller is gated upstream in InterfaceList).
|
||||
export default function ShareDialog({ ifaceId, ifaceName, folders, onClose, onSaved }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [folder, setFolder] = useState('');
|
||||
const [pub, setPub] = useState<'' | 'read' | 'write'>('');
|
||||
const [grants, setGrants] = useState<Grant[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<string[]>([]);
|
||||
|
||||
// New-grant row state.
|
||||
const [newKind, setNewKind] = useState<'user' | 'group'>('user');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPerm, setNewPerm] = useState<'read' | 'write'>('read');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`).then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/v1/usergroups').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([acl, groups]: [PanelACL | null, string[]]) => {
|
||||
if (acl) {
|
||||
setOwner(acl.owner || '');
|
||||
setFolder(acl.folder || '');
|
||||
setPub(acl.public || '');
|
||||
setGrants(Array.isArray(acl.grants) ? acl.grants : []);
|
||||
}
|
||||
setUserGroups(Array.isArray(groups) ? groups : []);
|
||||
})
|
||||
.catch(() => setError('Failed to load sharing settings.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ifaceId]);
|
||||
|
||||
function addGrant() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
// Replace an existing grant for the same kind+name rather than duplicating.
|
||||
const next = grants.filter(g => !(g.kind === newKind && g.name === name));
|
||||
next.push({ kind: newKind, name, perm: newPerm });
|
||||
setGrants(next);
|
||||
setNewName('');
|
||||
}
|
||||
|
||||
function removeGrant(idx: number) {
|
||||
setGrants(grants.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder, public: pub, grants }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.json().catch(() => null);
|
||||
throw new Error(msg?.error || `Save failed (${res.status})`);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Share — {ifaceName || ifaceId}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
{owner && (
|
||||
<p class="hint" style="padding: 0 0 0.75rem 0;">Owner: <strong>{owner}</strong></p>
|
||||
)}
|
||||
|
||||
<div class="wizard-section-title">Folder</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={folder} onChange={e => setFolder((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">(root — no folder)</option>
|
||||
{folders.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Public access</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={pub} onChange={e => setPub((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="">Private (only owner & shares)</option>
|
||||
<option value="read">Everyone can view</option>
|
||||
<option value="write">Everyone can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Shared with</div>
|
||||
{grants.length === 0 ? (
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Not shared with anyone yet.</p>
|
||||
) : (
|
||||
<ul class="iface-list" style="margin-bottom: 0.5rem;">
|
||||
{grants.map((g, idx) => (
|
||||
<li key={`${g.kind}:${g.name}`} class="iface-item" style="cursor: default;">
|
||||
<span>
|
||||
<span style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155; margin-right: 6px;">
|
||||
{g.kind}
|
||||
</span>
|
||||
<span style="color: #e2e8f0;">{g.name}</span>
|
||||
<span style="color: #64748b; margin-left: 6px;">({g.perm})</span>
|
||||
</span>
|
||||
<button class="icon-btn iface-delete" title="Remove" onClick={() => removeGrant(idx)}>✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div class="wizard-field wizard-field-row" style="gap: 0.5rem; align-items: center;">
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newKind} onChange={e => { setNewKind((e.target as HTMLSelectElement).value as any); setNewName(''); }}>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
{newKind === 'group' ? (
|
||||
<select class="prop-select" style="flex: 1;" value={newName} onChange={e => setNewName((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">Select group…</option>
|
||||
{userGroups.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
placeholder="username"
|
||||
value={newName}
|
||||
onInput={e => setNewName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addGrant()}
|
||||
/>
|
||||
)}
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newPerm} onChange={e => setNewPerm((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="panel-btn" onClick={addGrant} disabled={!newName.trim()}>Add</button>
|
||||
</div>
|
||||
|
||||
{error && <p class="wizard-error" style="margin-top: 0.75rem;">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={save} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+373
-153
@@ -1,7 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
import ChannelFinderModal from './ChannelFinderModal';
|
||||
|
||||
type TreeTab = 'sources' | 'groups';
|
||||
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
@@ -9,14 +15,17 @@ interface SignalInfo {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
writable?: boolean;
|
||||
properties?: Record<string, string>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface DsGroup {
|
||||
ds: string;
|
||||
signals: SignalInfo[];
|
||||
interface DisplayGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
signals: Array<SignalInfo & { ds: string }>;
|
||||
expanded: boolean;
|
||||
addingSignal: boolean; // show add-PV input
|
||||
addValue: string;
|
||||
addingSignal?: boolean;
|
||||
addValue?: string;
|
||||
}
|
||||
|
||||
// Custom signals persisted in localStorage
|
||||
@@ -36,41 +45,65 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
width?: number;
|
||||
// Current interface id — scopes panel-visibility synthetic signals.
|
||||
panelId?: string;
|
||||
// Panel-local state variables and a setter (edit mode only).
|
||||
statevars?: StateVar[];
|
||||
onStateVarsChange?: (vars: StateVar[]) => void;
|
||||
}
|
||||
|
||||
export default function SignalTree({ onDragStart }: Props) {
|
||||
export default function SignalTree({ onDragStart, width, panelId, statevars, onStateVarsChange }: Props) {
|
||||
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [groups, setGroups] = useState<DsGroup[]>([]);
|
||||
const [rawSignals, setRawSignals] = useState<Array<SignalInfo & { ds: string }>>([]);
|
||||
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cfAvailable, setCfAvailable] = useState(false);
|
||||
const [cfSearching, setCfSearching] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [showCfModal, setShowCfModal] = useState(false);
|
||||
const [showManualAdd, setShowManualAdd] = useState(false);
|
||||
const [manualDs, setManualDs] = useState('epics');
|
||||
const [manualName, setManualName] = useState('');
|
||||
const [addingTo, setAddingTo] = useState<string | null>(null);
|
||||
const [addValue, setAddValue] = useState('');
|
||||
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||
const csvRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function loadGroups() {
|
||||
async function loadAllSignals() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasources');
|
||||
if (!res.ok) return;
|
||||
const dsList: { name: string }[] = await res.json();
|
||||
const loaded: DsGroup[] = await Promise.all(
|
||||
|
||||
const all: Array<SignalInfo & { ds: string }> = [];
|
||||
await Promise.all(
|
||||
dsList.map(async ({ name }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
||||
return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' };
|
||||
} catch {
|
||||
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' };
|
||||
// Synthetic signals are filtered server-side by panel scope.
|
||||
const url = name === 'synthetic' && panelId
|
||||
? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}`
|
||||
: `/api/v1/signals?ds=${encodeURIComponent(name)}`;
|
||||
const r = await fetch(url);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
const sigs = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.signals) ? data.signals : []);
|
||||
all.push(...sigs.map(s => ({ ...s, ds: name })));
|
||||
}
|
||||
} catch (e) { console.error(`Failed to load ${name}:`, e); }
|
||||
})
|
||||
);
|
||||
setGroups(loaded);
|
||||
setRawSignals(all);
|
||||
|
||||
// Check if Channel Finder is available
|
||||
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
|
||||
setCfAvailable(cf?.status === 200);
|
||||
const cf = await fetch('/api/v1/channel-finder?q=')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
setCfAvailable(!!cf?.available);
|
||||
} catch (e) {
|
||||
console.error('Failed to load signals:', e);
|
||||
} finally {
|
||||
@@ -78,53 +111,100 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadGroups(); }, []);
|
||||
useEffect(() => { loadAllSignals(); }, [panelId]);
|
||||
|
||||
// Merge custom signals into groups
|
||||
const allGroups = groups.map(g => {
|
||||
const extra = customSignals.filter(c => c.ds === g.ds);
|
||||
const existing = new Set(g.signals.map(s => s.name));
|
||||
const merged = [
|
||||
...g.signals,
|
||||
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
|
||||
];
|
||||
return { ...g, signals: merged };
|
||||
// ── Local state variables (edit mode) ──────────────────────────────────────
|
||||
const [showAddLocal, setShowAddLocal] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(true);
|
||||
const [lvName, setLvName] = useState('');
|
||||
const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number');
|
||||
const [lvInitial, setLvInitial] = useState('0');
|
||||
|
||||
function addLocal() {
|
||||
const name = lvName.trim();
|
||||
if (!name || !onStateVarsChange) return;
|
||||
const next = (statevars ?? []).filter(v => v.name !== name);
|
||||
next.push({ name, type: lvType, initial: lvInitial });
|
||||
onStateVarsChange(next);
|
||||
setLvName('');
|
||||
setLvInitial('0');
|
||||
setShowAddLocal(false);
|
||||
}
|
||||
|
||||
function removeLocal(name: string) {
|
||||
if (!onStateVarsChange) return;
|
||||
onStateVarsChange((statevars ?? []).filter(v => v.name !== name));
|
||||
}
|
||||
|
||||
function makeLocalDraggable(name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds: 'local', name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Merge custom signals that might not be in ListSignals yet
|
||||
const allSignals = useMemo(() => {
|
||||
const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`));
|
||||
const extra = customSignals
|
||||
.filter(c => !existing.has(`${c.ds}\0${c.name}`))
|
||||
.map(c => ({ name: c.name, ds: c.ds, type: 'custom' } as SignalInfo & { ds: string }));
|
||||
return [...rawSignals, ...extra];
|
||||
}, [rawSignals, customSignals]);
|
||||
|
||||
// Group signals based on selected criteria
|
||||
const visibleGroups = useMemo(() => {
|
||||
const lf = filter.toLowerCase();
|
||||
const filtered = allSignals.filter(s =>
|
||||
s.name.toLowerCase().includes(lf) ||
|
||||
(s.description ?? '').toLowerCase().includes(lf)
|
||||
);
|
||||
|
||||
const groups: Record<string, Array<SignalInfo & { ds: string }>> = {};
|
||||
|
||||
filtered.forEach(s => {
|
||||
let key = 'unknown';
|
||||
if (groupBy === 'datasource') {
|
||||
key = s.ds;
|
||||
} else if (groupBy === 'area') {
|
||||
key = s.properties?.area || 'no-area';
|
||||
} else if (groupBy === 'system') {
|
||||
key = s.properties?.system || 'no-system';
|
||||
} else if (groupBy === 'ioc') {
|
||||
key = s.properties?.iocName || 'no-ioc';
|
||||
}
|
||||
(groups[key] ??= []).push(s);
|
||||
});
|
||||
|
||||
// Custom signals with no matching DS group get their own group
|
||||
const knownDs = new Set(groups.map(g => g.ds));
|
||||
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds));
|
||||
const orphanGroups: DsGroup[] = Object.entries(
|
||||
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => {
|
||||
(acc[ds] ??= []).push(name);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([ds, names]) => ({
|
||||
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
|
||||
expanded: true, addingSignal: false, addValue: '',
|
||||
}));
|
||||
return Object.entries(groups).map(([id, signals]) => ({
|
||||
id,
|
||||
label: id,
|
||||
signals: signals.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
expanded: expandedGroups[id] ?? true,
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [allSignals, groupBy, expandedGroups, filter]);
|
||||
|
||||
const visibleGroups = [...allGroups, ...orphanGroups];
|
||||
|
||||
function toggleGroup(ds: string) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
||||
function toggleGroup(id: string) {
|
||||
setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) }));
|
||||
}
|
||||
|
||||
function setAdding(ds: string, val: boolean) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
|
||||
function startAdding(ds: string) {
|
||||
setAddingTo(ds);
|
||||
setAddValue('');
|
||||
setExpandedGroups(prev => ({ ...prev, [ds]: true }));
|
||||
}
|
||||
|
||||
function setAddValue(ds: string, val: string) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g));
|
||||
}
|
||||
|
||||
function commitAdd(ds: string, name: string) {
|
||||
name = name.trim();
|
||||
if (!name) { setAdding(ds, false); return; }
|
||||
const next = [...customSignals, { ds, name }];
|
||||
function commitAddManually() {
|
||||
const name = addValue.trim();
|
||||
if (!name || !addingTo) { setAddingTo(null); return; }
|
||||
const next = [...customSignals, { ds: addingTo, name }];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
setAdding(ds, false);
|
||||
setAddingTo(null);
|
||||
}
|
||||
|
||||
function removeCustom(ds: string, name: string) {
|
||||
@@ -133,26 +213,21 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
saveCustom(next);
|
||||
}
|
||||
|
||||
// ── Channel Finder search ────────────────────────────────────────────────
|
||||
|
||||
async function handleCfSearch() {
|
||||
if (!filter) return;
|
||||
setCfSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`);
|
||||
if (!res.ok) return;
|
||||
const names: string[] = await res.json();
|
||||
const next = [
|
||||
...customSignals,
|
||||
...names
|
||||
.filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n))
|
||||
.map(n => ({ ds: 'epics', name: n })),
|
||||
];
|
||||
function handleAddCfSignals(refs: SignalRef[]) {
|
||||
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
||||
const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`));
|
||||
if (fresh.length === 0) return;
|
||||
const next = [...customSignals, ...fresh];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
} finally {
|
||||
setCfSearching(false);
|
||||
loadAllSignals(); // Refresh to fetch metadata for new ones
|
||||
}
|
||||
|
||||
function handleManualAdd() {
|
||||
if (!manualName.trim()) return;
|
||||
handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
|
||||
setManualName('');
|
||||
setShowManualAdd(false);
|
||||
}
|
||||
|
||||
// ── CSV import ────────────────────────────────────────────────────────────
|
||||
@@ -169,39 +244,19 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
if (parts.length === 1 && parts[0]) {
|
||||
// bare PV name → default to epics
|
||||
added.push({ ds: 'epics', name: parts[0] });
|
||||
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
added.push({ ds: parts[0], name: parts[1] });
|
||||
}
|
||||
}
|
||||
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
||||
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
|
||||
const next = [...customSignals, ...fresh];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
handleAddCfSignals(added);
|
||||
}
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────────
|
||||
|
||||
const lf = filter.toLowerCase();
|
||||
const filtered = visibleGroups
|
||||
.map(g => ({
|
||||
...g,
|
||||
signals: lf
|
||||
? g.signals.filter(s =>
|
||||
s.name.toLowerCase().includes(lf) ||
|
||||
(s.description ?? '').toLowerCase().includes(lf)
|
||||
)
|
||||
: g.signals,
|
||||
}))
|
||||
.filter(g => g.signals.length > 0 || g.addingSignal || !lf);
|
||||
|
||||
function makeDraggable(ds: string, sig: SignalInfo) {
|
||||
function makeDraggable(sig: SignalInfo & { ds: string }) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds, name: sig.name };
|
||||
const ref: SignalRef = { ds: sig.ds, name: sig.name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
@@ -209,7 +264,7 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Signals</span>}
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
@@ -219,7 +274,101 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
|
||||
{!collapsed && (
|
||||
<div class="signal-tree-body">
|
||||
{/* Search / filter bar */}
|
||||
<div class="signal-tree-tabs">
|
||||
<button
|
||||
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('sources')}
|
||||
>Sources</button>
|
||||
<button
|
||||
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('groups')}
|
||||
>Groups</button>
|
||||
</div>
|
||||
|
||||
{treeTab === 'groups' && (
|
||||
<GroupsTree onDragStart={onDragStart} />
|
||||
)}
|
||||
|
||||
{treeTab === 'sources' && onStateVarsChange && (
|
||||
<div class="signal-group local-state-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => setLocalOpen(o => !o)}>
|
||||
{localOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" title="Panel-local state variables" onClick={() => setLocalOpen(o => !o)}>
|
||||
Local State
|
||||
</span>
|
||||
<span class="signal-group-count">{(statevars ?? []).length}</span>
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title="Add local state variable"
|
||||
onClick={(e) => { e.stopPropagation(); setShowAddLocal(s => !s); setLocalOpen(true); }}
|
||||
>+</button>
|
||||
</div>
|
||||
{localOpen && (
|
||||
<div>
|
||||
{showAddLocal && (
|
||||
<div class="signal-add-row" style="flex-wrap: wrap; gap: 4px;">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Variable name…"
|
||||
autoFocus
|
||||
value={lvName}
|
||||
onInput={(e) => setLvName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') addLocal();
|
||||
if (e.key === 'Escape') setShowAddLocal(false);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
class="prop-select"
|
||||
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
||||
value={lvType}
|
||||
onChange={(e) => setLvType((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
<option value="number">number</option>
|
||||
<option value="bool">bool</option>
|
||||
<option value="string">string</option>
|
||||
</select>
|
||||
<input
|
||||
class="signal-add-input"
|
||||
style="flex: 1;"
|
||||
placeholder="initial"
|
||||
value={lvInitial}
|
||||
onInput={(e) => setLvInitial((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
|
||||
/>
|
||||
<button class="icon-btn" title="Add" onClick={addLocal}>✓</button>
|
||||
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{(statevars ?? []).length === 0 && !showAddLocal && (
|
||||
<p class="hint" style="padding: 0.25rem 0.5rem;">No local variables.</p>
|
||||
)}
|
||||
{(statevars ?? []).map(v => (
|
||||
<div
|
||||
key={v.name}
|
||||
class="signal-item signal-custom"
|
||||
title={`local:${v.name} (${v.type ?? 'number'}, initial ${v.initial})`}
|
||||
{...makeLocalDraggable(v.name)}
|
||||
>
|
||||
<span class="signal-name">{v.name}</span>
|
||||
<span class="signal-unit">{v.type ?? 'number'}</span>
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove local variable"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeLocal(v.name); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{treeTab === 'sources' && (
|
||||
<Fragment>
|
||||
<div class="signal-tree-search">
|
||||
<div class="signal-search-row">
|
||||
<input
|
||||
@@ -229,107 +378,178 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{cfAvailable && filter && (
|
||||
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
|
||||
{cfSearching ? '…' : '🔍'}
|
||||
{cfAvailable && (
|
||||
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
|
||||
🔍
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
|
||||
<div class="signal-tree-toolbar">
|
||||
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
|
||||
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}>↺</button>
|
||||
<div class="signal-tree-toolbar" style="gap: 4px;">
|
||||
<select
|
||||
class="prop-select"
|
||||
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
|
||||
value={groupBy}
|
||||
onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
<option value="datasource">By Source</option>
|
||||
<option value="area">By Area</option>
|
||||
<option value="system">By System</option>
|
||||
<option value="ioc">By IOC</option>
|
||||
</select>
|
||||
<button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
|
||||
<button class="panel-btn icon-only" title="Create synthetic signal" onClick={() => setShowWizard(true)}>Σ</button>
|
||||
<button class="panel-btn icon-only" title="Import CSV" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||
<button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}>↺</button>
|
||||
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
||||
</div>
|
||||
|
||||
{/* Signal groups */}
|
||||
<div class="panel-list signal-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading signals…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
) : visibleGroups.length === 0 ? (
|
||||
<p class="hint">No signals found.</p>
|
||||
) : (
|
||||
filtered.map(group => {
|
||||
const groupInGroups = groups.find(g => g.ds === group.ds);
|
||||
return (
|
||||
<div key={group.ds} class="signal-group">
|
||||
visibleGroups.map(group => (
|
||||
<div key={group.id} class="signal-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
|
||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
|
||||
{group.expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
|
||||
{group.ds}
|
||||
<span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
|
||||
{group.label}
|
||||
</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
{groupBy === 'datasource' && group.id !== 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title={`Add custom signal to ${group.ds}`}
|
||||
onClick={() => setAdding(group.ds, true)}
|
||||
title={`Add custom signal to ${group.id}`}
|
||||
onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
|
||||
>+</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{group.expanded && (
|
||||
<div>
|
||||
{group.signals.map(sig => {
|
||||
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
|
||||
return (
|
||||
<div
|
||||
key={sig.name}
|
||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
{...makeDraggable(group.ds, sig)}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove custom signal"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add-signal input row */}
|
||||
{(groupInGroups?.addingSignal) && (
|
||||
{addingTo === group.id && (
|
||||
<div class="signal-add-row">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Signal / PV name…"
|
||||
autoFocus
|
||||
value={groupInGroups?.addValue ?? ''}
|
||||
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
|
||||
value={addValue}
|
||||
onInput={(e) => setAddValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
|
||||
if (e.key === 'Escape') setAdding(group.ds, false);
|
||||
if (e.key === 'Enter') commitAddManually();
|
||||
if (e.key === 'Escape') setAddingTo(null);
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}>✓</button>
|
||||
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}>✕</button>
|
||||
<button class="icon-btn" onClick={commitAddManually}>✓</button>
|
||||
<button class="icon-btn" onClick={() => setAddingTo(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{group.signals.map(sig => {
|
||||
const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name);
|
||||
return (
|
||||
<div
|
||||
key={`${sig.ds}:${sig.name}`}
|
||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||
title={`${sig.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
{...makeDraggable(sig)}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{sig.ds === 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-edit-btn"
|
||||
title="Edit pipeline"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
|
||||
>✎</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove custom signal"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(sig.ds, sig.name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
currentIfaceId={panelId}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadGroups}
|
||||
onCreated={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editSynthetic && (
|
||||
<SyntheticGraphEditor
|
||||
name={editSynthetic}
|
||||
onClose={() => setEditSynthetic(null)}
|
||||
onSaved={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCfModal && (
|
||||
<ChannelFinderModal
|
||||
cfURL="" // fetched by backend API
|
||||
onClose={() => setShowCfModal(false)}
|
||||
onAddSignals={handleAddCfSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showManualAdd && (
|
||||
<div class="wizard-backdrop" onClick={() => setShowManualAdd(false)}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 400px;">
|
||||
<div class="wizard-header">
|
||||
<span>Add Signal Manually</span>
|
||||
<button class="icon-btn" onClick={() => setShowManualAdd(false)}>✕</button>
|
||||
</div>
|
||||
<div class="wizard-body">
|
||||
<div class="wizard-field">
|
||||
<label>Data Source</label>
|
||||
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
|
||||
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
|
||||
<option key={ds} value={ds}>{ds}</option>
|
||||
))}
|
||||
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal / PV Name</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="e.g. MY:PV:NAME"
|
||||
value={manualName}
|
||||
onInput={e => setManualName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={() => setShowManualAdd(false)}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleManualAdd}>Add Signal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { h } from 'preact';
|
||||
import type { VNode } from 'preact';
|
||||
import type { PlotLayout } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
layout: PlotLayout;
|
||||
/** Render the contents of a leaf pane for the given widget id. */
|
||||
renderLeaf: (widgetId: string) => VNode;
|
||||
/** When provided, dividers become draggable and report the new ratio for the
|
||||
* split node addressed by `path` (a list of 0/1 = a/b choices from the root). */
|
||||
onResize?: (path: number[], ratio: number) => void;
|
||||
}
|
||||
|
||||
const MIN_RATIO = 0.1;
|
||||
const MAX_RATIO = 0.9;
|
||||
|
||||
export default function SplitLayout({ layout, renderLeaf, onResize }: Props) {
|
||||
function startDrag(e: MouseEvent, container: HTMLElement | null, dir: 'h' | 'v', path: number[]) {
|
||||
if (!onResize || !container) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
function onMove(mv: MouseEvent) {
|
||||
const rect = container!.getBoundingClientRect();
|
||||
const r = dir === 'h'
|
||||
? (mv.clientX - rect.left) / rect.width
|
||||
: (mv.clientY - rect.top) / rect.height;
|
||||
onResize!(path, Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)));
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function renderNode(node: PlotLayout, path: number[]): VNode {
|
||||
if (node.type === 'leaf') {
|
||||
return <div class="split-leaf">{renderLeaf(node.widget)}</div>;
|
||||
}
|
||||
const isRow = node.dir === 'h';
|
||||
let containerEl: HTMLElement | null = null;
|
||||
return (
|
||||
<div
|
||||
class={isRow ? 'split-row' : 'split-col'}
|
||||
ref={(el) => { containerEl = el as HTMLElement | null; }}
|
||||
>
|
||||
<div class="split-child" style={`flex:${node.ratio}`}>
|
||||
{renderNode(node.a, [...path, 0])}
|
||||
</div>
|
||||
<div
|
||||
class={`split-divider ${isRow ? 'split-divider-h' : 'split-divider-v'}${onResize ? '' : ' split-divider-static'}`}
|
||||
onMouseDown={(e: MouseEvent) => startDrag(e, containerEl, node.dir, path)}
|
||||
/>
|
||||
<div class="split-child" style={`flex:${1 - node.ratio}`}>
|
||||
{renderNode(node.b, [...path, 1])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div class="split-root">{renderNode(layout, [])}</div>;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ── Node-type catalogue ──────────────────────────────────────────────────────
|
||||
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
|
||||
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
|
||||
// pipeline receives — see the compile() note below.
|
||||
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
|
||||
interface OpDef { type: string; label: string; params: NodeParam[]; }
|
||||
|
||||
const OPS: OpDef[] = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
|
||||
{ type: 'subtract', label: 'Subtract (a−b)', params: [] },
|
||||
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
|
||||
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'threshold', label: 'Threshold', params: [
|
||||
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
|
||||
{ label: 'High output', key: 'high', type: 'number', default: '1' },
|
||||
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
|
||||
]},
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
|
||||
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
|
||||
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
|
||||
|
||||
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
|
||||
type NodeKind = 'source' | 'op' | 'output';
|
||||
interface GNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
ds?: string; // source
|
||||
signal?: string; // source
|
||||
op?: string; // op type
|
||||
params?: Record<string, any>; // op
|
||||
}
|
||||
interface GWire { from: string; to: string; }
|
||||
interface Graph { nodes: GNode[]; wires: GWire[]; }
|
||||
|
||||
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
||||
const REM = (() => {
|
||||
if (typeof document === 'undefined') return 16;
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
})();
|
||||
const NODE_W = 10 * REM;
|
||||
const PORT_TOP = 1.4 * REM;
|
||||
const PORT_R = 0.375 * REM;
|
||||
|
||||
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
||||
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
|
||||
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
||||
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
// Build an initial graph by laying out an existing SignalDef: sources stacked on
|
||||
// the left, the linear pipeline as a row of op nodes, output on the right.
|
||||
function buildInitial(def: SignalDef): Graph {
|
||||
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
|
||||
? def.inputs
|
||||
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
|
||||
const pipeline = def.pipeline ?? [];
|
||||
const nodes: GNode[] = [];
|
||||
const wires: GWire[] = [];
|
||||
|
||||
const srcIds = inputs.map((inp, i) => {
|
||||
const id = genId();
|
||||
nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal });
|
||||
return id;
|
||||
});
|
||||
const opIds = pipeline.map((nd, i) => {
|
||||
const id = genId();
|
||||
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
|
||||
return id;
|
||||
});
|
||||
const outId = genId();
|
||||
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
|
||||
|
||||
const headId = opIds[0] ?? outId;
|
||||
srcIds.forEach(s => wires.push({ from: s, to: headId }));
|
||||
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
|
||||
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
|
||||
|
||||
return { nodes, wires };
|
||||
}
|
||||
|
||||
// Compile the visual graph back into the backend's linear form. The pipeline is
|
||||
// a single chain ending at the output; the head node receives every source
|
||||
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
|
||||
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
|
||||
const output = g.nodes.find(n => n.kind === 'output');
|
||||
if (!output) return { error: 'missing output node' };
|
||||
const byId = new Map(g.nodes.map(n => [n.id, n]));
|
||||
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
|
||||
|
||||
const chain: GNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: GNode = output;
|
||||
for (;;) {
|
||||
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
|
||||
seen.add(cur.id);
|
||||
const ups = upstream(cur.id);
|
||||
const opUps = ups.filter(n => n.kind === 'op');
|
||||
const srcUps = ups.filter(n => n.kind === 'source');
|
||||
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
|
||||
if (opUps.length === 1) {
|
||||
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
|
||||
chain.unshift(opUps[0]);
|
||||
cur = opUps[0];
|
||||
continue;
|
||||
}
|
||||
// Reached the head of the chain: its source upstreams become the inputs.
|
||||
if (srcUps.length === 0) {
|
||||
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
|
||||
}
|
||||
const inputs = srcUps
|
||||
.slice()
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
|
||||
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
|
||||
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
|
||||
return { inputs, pipeline };
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSummary(n: GNode): string {
|
||||
if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)';
|
||||
if (n.kind === 'output') return 'synthetic result';
|
||||
const p = n.params ?? {};
|
||||
switch (n.op) {
|
||||
case 'gain': return `× ${p.gain ?? 1}`;
|
||||
case 'offset': return `+ ${p.offset ?? 0}`;
|
||||
case 'moving_average': return `avg ${p.window ?? 10}`;
|
||||
case 'rms': return `rms ${p.window ?? 10}`;
|
||||
case 'lowpass': return `≤ ${p.freq ?? 1} Hz`;
|
||||
case 'clamp': return `[${p.min ?? 0}, ${p.max ?? 100}]`;
|
||||
case 'threshold': return `> ${p.threshold ?? 0}`;
|
||||
case 'expr': return String(p.expr ?? '');
|
||||
case 'lua': return 'lua';
|
||||
default: return opLabel(n.op ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
|
||||
const [def, setDef] = useState<SignalDef | null>(null);
|
||||
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
|
||||
const pendingRef = useRef(pendingWire);
|
||||
pendingRef.current = pendingWire;
|
||||
|
||||
const graphRef = useRef(graph);
|
||||
graphRef.current = graph;
|
||||
const undoStack = useRef<Graph[]>([]);
|
||||
const redoStack = useRef<Graph[]>([]);
|
||||
const [, setTick] = useState(0);
|
||||
const bump = () => setTick(t => t + 1);
|
||||
const canUndo = undoStack.current.length > 0;
|
||||
const canRedo = redoStack.current.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (!ds || dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||
.then((d: SignalDef) => {
|
||||
setDef(d);
|
||||
const g = buildInitial(d);
|
||||
setGraph(g);
|
||||
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
||||
redoStack.current = [];
|
||||
}
|
||||
function commit(next: Graph, record = true) {
|
||||
if (record) pushUndo();
|
||||
setGraph(next);
|
||||
}
|
||||
function undo() {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [graphRef.current, ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setSelected(null); setSelectedWire(null);
|
||||
setGraph(prev); bump();
|
||||
}
|
||||
function redo() {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, graphRef.current];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setSelected(null); setSelectedWire(null);
|
||||
setGraph(next); bump();
|
||||
}
|
||||
|
||||
// ── Graph mutators ───────────────────────────────────────────────────────
|
||||
function addSource() {
|
||||
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' };
|
||||
if (node.ds) loadSignals(node.ds);
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
function addOp(op: OpDef, x?: number, y?: number) {
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
function patchNode(id: string, patch: Partial<GNode>) {
|
||||
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires });
|
||||
}
|
||||
function patchParam(id: string, key: string, val: string) {
|
||||
commit({
|
||||
nodes: graph.nodes.map(n => {
|
||||
if (n.id !== id) return n;
|
||||
const pd = opParamDefs(n.op ?? '').find(p => p.key === key);
|
||||
const typed: any = pd?.type === 'number' ? parseFloat(val) : val;
|
||||
return { ...n, params: { ...n.params, [key]: typed } };
|
||||
}),
|
||||
wires: graph.wires,
|
||||
});
|
||||
}
|
||||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
||||
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
|
||||
}
|
||||
function deleteNode(id: string) {
|
||||
const n = graph.nodes.find(x => x.id === id);
|
||||
if (!n || n.kind === 'output') return; // the output node is permanent
|
||||
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
|
||||
if (selected === id) setSelected(null);
|
||||
}
|
||||
function addWire(from: string, to: string) {
|
||||
if (from === to) return;
|
||||
const fn = graph.nodes.find(n => n.id === from);
|
||||
const tn = graph.nodes.find(n => n.id === to);
|
||||
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
|
||||
if (graph.wires.some(w => w.from === from && w.to === to)) return;
|
||||
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
|
||||
}
|
||||
function deleteWire(idx: number) {
|
||||
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
|
||||
if (selectedWire === idx) setSelectedWire(null);
|
||||
}
|
||||
|
||||
// ── Pointer / drag / wire ──────────────────────────────────────────────────
|
||||
function toCanvas(e: MouseEvent) {
|
||||
const el = canvasRef.current!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
|
||||
}
|
||||
function startNodeDrag(e: MouseEvent, node: GNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
window.addEventListener('mousemove', onNodeDragMove);
|
||||
window.addEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function onNodeDragMove(e: MouseEvent) {
|
||||
const d = dragNode.current;
|
||||
if (!d) return;
|
||||
const p = toCanvas(e);
|
||||
const record = !d.pushed;
|
||||
d.pushed = true;
|
||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
|
||||
}
|
||||
function onNodeDragUp() {
|
||||
dragNode.current = null;
|
||||
window.removeEventListener('mousemove', onNodeDragMove);
|
||||
window.removeEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function startWire(e: MouseEvent, node: GNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ from: node.id, x: p.x, y: p.y });
|
||||
window.addEventListener('mousemove', onWireMove);
|
||||
window.addEventListener('mouseup', onWireUp);
|
||||
}
|
||||
function onWireMove(e: MouseEvent) {
|
||||
const cur = pendingRef.current;
|
||||
if (!cur) return;
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ ...cur, x: p.x, y: p.y });
|
||||
}
|
||||
function endWire() {
|
||||
pendingRef.current = null;
|
||||
window.removeEventListener('mousemove', onWireMove);
|
||||
window.removeEventListener('mouseup', onWireUp);
|
||||
setPendingWire(null);
|
||||
}
|
||||
function onWireUp() { endWire(); }
|
||||
function finishWire(target: GNode) {
|
||||
const cur = pendingRef.current;
|
||||
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
|
||||
endWire();
|
||||
}
|
||||
|
||||
// ── Palette drag-and-drop ──────────────────────────────────────────────────
|
||||
const DRAG_MIME = 'application/x-uopi-synth-op';
|
||||
function onCanvasDragOver(e: DragEvent) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
function onCanvasDrop(e: DragEvent) {
|
||||
const type = e.dataTransfer?.getData(DRAG_MIME);
|
||||
if (!type) return;
|
||||
e.preventDefault();
|
||||
const op = OP_BY_TYPE.get(type);
|
||||
if (!op) return;
|
||||
const p = toCanvas(e);
|
||||
addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP));
|
||||
}
|
||||
|
||||
// ── Keyboard ────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as Element;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selected) deleteNode(selected);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selected, selectedWire, graph]);
|
||||
|
||||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||||
const compiled = compile(graph);
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
if (compiled.error) { setError(compiled.error); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeClass(n: GNode): string {
|
||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="wizard-header">
|
||||
<span>Synthetic Signal — {name}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div class="wizard-body"><p class="hint">Loading…</p></div>
|
||||
) : (
|
||||
<div class="flow-editor">
|
||||
<div class="flow-palette">
|
||||
<div class="flow-palette-toolbar">
|
||||
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
|
||||
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<div class="flow-palette-title">Inputs</div>
|
||||
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
|
||||
<div class="flow-palette-title">Operations</div>
|
||||
{OPS.map(op => (
|
||||
<button key={op.type} class="flow-palette-btn flow-palette-flow"
|
||||
title={`${op.type} — drag onto the canvas or click to add`}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
|
||||
onClick={() => addOp(op)}>{op.label}</button>
|
||||
))}
|
||||
<div class="flow-palette-hint hint">
|
||||
Wire input signals into the first operation, chain operations, and connect the
|
||||
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-canvas" ref={canvasRef}
|
||||
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}>
|
||||
<div class="flow-canvas-inner">
|
||||
<svg class="flow-wires">
|
||||
{graph.wires.map((w, idx) => {
|
||||
const a = byId.get(w.from); const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a); const p2 = inAnchor(b);
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
|
||||
);
|
||||
})}
|
||||
{pendingWire && (() => {
|
||||
const a = byId.get(pendingWire.from);
|
||||
if (!a) return null;
|
||||
const p1 = outAnchor(a);
|
||||
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{graph.nodes.map(node => (
|
||||
<div key={node.id}
|
||||
class={nodeClass(node)}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
||||
{node.kind !== 'output' && (
|
||||
<button class="flow-node-del" title="Delete node"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||
|
||||
{hasInput(node.kind) && (
|
||||
<div class="flow-port flow-port-in" title="Input"
|
||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
||||
)}
|
||||
{hasOutput(node.kind) && (
|
||||
<div class="flow-port flow-port-out" title="Output"
|
||||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => startWire(e, node)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-inspector">
|
||||
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
|
||||
|
||||
{sel?.kind === 'source' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">Input signal</div>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
|
||||
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
|
||||
onSelect={(signal) => patchNode(sel.id, { signal })}
|
||||
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{sel?.kind === 'op' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
|
||||
{opParamDefs(sel.op ?? '').length === 0 && (
|
||||
<p class="hint">No parameters — this operation transforms its input directly.</p>
|
||||
)}
|
||||
{opParamDefs(sel.op ?? '').map(pd => (
|
||||
<div key={pd.key} class="wizard-field">
|
||||
<label>{pd.label}</label>
|
||||
{pd.type === 'lua' ? (
|
||||
<LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
|
||||
onChange={(v) => patchParam(sel.id, pd.key, v)} />
|
||||
) : (
|
||||
<input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
|
||||
value={sel.params?.[pd.key] ?? pd.default}
|
||||
onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{sel?.kind === 'output' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">Output</div>
|
||||
<p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="wizard-footer">
|
||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
|
||||
{saving ? 'Saving…' : 'Save Signal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+127
-30
@@ -1,37 +1,58 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text';
|
||||
type: 'number' | 'text' | 'lua';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] },
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_avg', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Lowpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'highpass', label: 'Highpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass Filter', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integral', label: 'Integral', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
interface DataSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputDs, setInputDs] = useState('stub');
|
||||
const [inputSig, setInputSig] = useState('sine_1hz');
|
||||
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||
const [nodeType, setNodeType] = useState('gain');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
// Visibility scope. Panel scope requires an interface to bind to, so when the
|
||||
// wizard is opened outside a saved panel it falls back to 'user'.
|
||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [dispLow, setDispLow] = useState('0');
|
||||
@@ -39,6 +60,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Loaded data
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
|
||||
|
||||
function getParam(key: string, def: string): string {
|
||||
@@ -49,9 +91,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
setParams(p => ({ ...p, [key]: val }));
|
||||
}
|
||||
|
||||
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||
setInputs(prev => prev.map((inp, i) => {
|
||||
if (i !== idx) return inp;
|
||||
const next = { ...inp, ...patch };
|
||||
if (patch.ds) {
|
||||
next.signal = '';
|
||||
loadSignals(patch.ds);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
}
|
||||
|
||||
function addInput() {
|
||||
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
|
||||
}
|
||||
|
||||
function removeInput(idx: number) {
|
||||
setInputs(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError('Name is required'); return; }
|
||||
if (!inputSig.trim()) { setError('Input signal is required'); return; }
|
||||
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
|
||||
|
||||
const nodeParams: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
@@ -59,10 +121,9 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
|
||||
}
|
||||
|
||||
const def = {
|
||||
const def: SignalDef = {
|
||||
name: name.trim(),
|
||||
ds: inputDs,
|
||||
signal: inputSig.trim(),
|
||||
inputs,
|
||||
pipeline: [{ type: nodeType, params: nodeParams }],
|
||||
meta: {
|
||||
unit: unit || undefined,
|
||||
@@ -70,6 +131,8 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
displayLow: parseFloat(dispLow) || 0,
|
||||
displayHigh: parseFloat(dispHigh) || 100,
|
||||
},
|
||||
visibility,
|
||||
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -112,23 +175,50 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Input signal</div>
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<input class="prop-input" value={inputDs}
|
||||
placeholder="stub"
|
||||
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal name</label>
|
||||
<input class="prop-input" value={inputSig}
|
||||
placeholder="sine_1hz"
|
||||
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<label>Visibility</label>
|
||||
<select class="prop-select" value={visibility}
|
||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="panel" disabled={!currentIfaceId}>
|
||||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||||
</option>
|
||||
<option value="user">All my panels</option>
|
||||
<option value="global">All panels (global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Processing</div>
|
||||
<div class="wizard-section-title">Input signals</div>
|
||||
{inputs.map((inp, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect
|
||||
value={inp.ds}
|
||||
options={dataSources}
|
||||
onSelect={(ds) => updateInput(idx, { ds })}
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect
|
||||
value={inp.signal}
|
||||
options={dsSignals[inp.ds] || []}
|
||||
onSelect={(signal) => updateInput(idx, { signal })}
|
||||
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
style="margin-bottom: 0.25rem;"
|
||||
title="Remove input"
|
||||
onClick={() => removeInput(idx)}
|
||||
disabled={inputs.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||||
<div class="wizard-field">
|
||||
<label>Node type</label>
|
||||
<select class="prop-select" value={nodeType}
|
||||
@@ -139,9 +229,16 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
{nodeDef.params.map(p => (
|
||||
<div key={p.key} class="wizard-field">
|
||||
<label>{p.label}</label>
|
||||
{p.type === 'lua' ? (
|
||||
<LuaEditor
|
||||
value={getParam(p.key, p.default)}
|
||||
onChange={(v) => setParam(p.key, v)}
|
||||
/>
|
||||
) : (
|
||||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||
value={getParam(p.key, p.default)}
|
||||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
+78
-4
@@ -4,12 +4,19 @@ import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import { newPlotPanel } from './lib/templates';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import ControlLogicEditor from './ControlLogicEditor';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
initialInterface?: Interface | null;
|
||||
/** Notifies the parent which interface is currently shown (for edit return). */
|
||||
onView?: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
||||
@@ -18,13 +25,33 @@ function toLocalInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function ViewMode({ onEdit }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
||||
export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [listCollapsed, setListCollapsed] = useState(false);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = leftW;
|
||||
function onMove(mv: MouseEvent) {
|
||||
setLeftW(Math.max(140, startW + mv.clientX - startX));
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||
|
||||
@@ -39,6 +66,18 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// When returning from edit mode, show the edited interface
|
||||
useEffect(() => {
|
||||
if (initialInterface) setCurrentInterface(initialInterface);
|
||||
}, [initialInterface]);
|
||||
|
||||
// Auto-hide the interface-selection pane whenever a panel is opened, and keep
|
||||
// the parent informed of what's shown (so closing the editor can return here).
|
||||
useEffect(() => {
|
||||
if (currentInterface) setListCollapsed(true);
|
||||
onView?.(currentInterface);
|
||||
}, [currentInterface]);
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
setParseError(null);
|
||||
@@ -100,7 +139,7 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
<div class="toolbar-right">
|
||||
<div class={`status-chip ${wsStatus}`}>
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus}
|
||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||
</div>
|
||||
<button
|
||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||
@@ -117,6 +156,17 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
>
|
||||
📖
|
||||
</button>
|
||||
<ZoomControl />
|
||||
{writable && me.canEditLogic && (
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowControlLogic(true)}
|
||||
title="Open server-side control logic"
|
||||
>
|
||||
⚙ Control logic
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
@@ -124,6 +174,12 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{me.user && (
|
||||
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
|
||||
{me.user}{!writable && ' (read-only)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -166,7 +222,11 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onNewPlot={() => onEdit?.(newPlotPanel())}
|
||||
onEditId={handleEditById}
|
||||
width={leftW}
|
||||
collapsed={listCollapsed}
|
||||
onToggleCollapse={() => setListCollapsed(c => !c)}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
@@ -177,12 +237,26 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
|
||||
<div class="panel-resize-handle" onMouseDown={startResize} />
|
||||
|
||||
<div class="view-content-area">
|
||||
<div class="view-panel-container active">
|
||||
<Canvas
|
||||
iface={currentInterface}
|
||||
onNavigate={handleNavigate}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
{showControlLogic && (
|
||||
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
const LS_KEY = 'uopi:ui-zoom';
|
||||
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
|
||||
|
||||
export function getStoredZoom(): number {
|
||||
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
|
||||
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
|
||||
}
|
||||
|
||||
export function applyZoom(z: number): number {
|
||||
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
|
||||
localStorage.setItem(LS_KEY, String(clamped));
|
||||
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export default function ZoomControl() {
|
||||
const [zoom, setZoom] = useState(getStoredZoom);
|
||||
|
||||
function step(dir: number) {
|
||||
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
|
||||
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
|
||||
setZoom(applyZoom(STEPS[nextIdx]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="zoom-control" title="UI zoom">
|
||||
<button class="icon-btn zoom-btn" onClick={() => step(-1)} title="Zoom out (smaller UI)">A−</button>
|
||||
<span class="zoom-pct">{Math.round(zoom * 100)}%</span>
|
||||
<button class="icon-btn zoom-btn" onClick={() => step(1)} title="Zoom in (larger UI)">A+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import type { Me, AccessLevel } from './types';
|
||||
|
||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||
// trusted LAN user with full access, matching the backend default.
|
||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
|
||||
|
||||
// AuthContext carries the resolved identity + global access level for the
|
||||
// current user throughout the app.
|
||||
export const AuthContext = createContext<Me>(DEFAULT_ME);
|
||||
|
||||
export function useAuth(): Me {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// canWrite reports whether the given level permits creating/modifying panels
|
||||
// and writing signal values.
|
||||
export function canWrite(level: AccessLevel): boolean {
|
||||
return level === 'write';
|
||||
}
|
||||
|
||||
// canRead reports whether the given level permits viewing panels at all.
|
||||
export function canRead(level: AccessLevel): boolean {
|
||||
return level !== 'none';
|
||||
}
|
||||
|
||||
// fetchMe loads the caller's identity from the backend. On failure it falls
|
||||
// back to the trusted-LAN default so the UI stays usable in dev/unproxied mode.
|
||||
export async function fetchMe(): Promise<Me> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/me');
|
||||
if (!res.ok) return DEFAULT_ME;
|
||||
const data = await res.json();
|
||||
return {
|
||||
user: typeof data.user === 'string' ? data.user : '',
|
||||
level: (data.level as AccessLevel) ?? 'write',
|
||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||
canEditLogic: data.canEditLogic !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Small, safe expression evaluator for panel logic.
|
||||
//
|
||||
// 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 panel-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 touches the DOM or eval;
|
||||
// it walks a parsed AST against a caller-supplied resolver.
|
||||
|
||||
export interface RefLite { ds: string; name: string }
|
||||
|
||||
type Node =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'sig'; ds: string; name: string }
|
||||
| { t: 'var'; name: string }
|
||||
| { t: 'un'; op: string; a: Node }
|
||||
| { t: 'bin'; op: string; a: Node; b: Node }
|
||||
| { t: 'tern'; c: Node; a: Node; b: Node }
|
||||
| { t: 'call'; fn: string; args: Node[] };
|
||||
|
||||
const FUNCS: Record<string, (a: number[]) => number> = {
|
||||
abs: a => Math.abs(a[0]),
|
||||
min: a => Math.min(...a),
|
||||
max: a => Math.max(...a),
|
||||
sqrt: a => Math.sqrt(a[0]),
|
||||
floor: a => Math.floor(a[0]),
|
||||
ceil: a => Math.ceil(a[0]),
|
||||
round: a => Math.round(a[0]),
|
||||
sign: a => Math.sign(a[0]),
|
||||
pow: a => Math.pow(a[0], a[1]),
|
||||
log: a => Math.log(a[0]),
|
||||
exp: a => Math.exp(a[0]),
|
||||
sin: a => Math.sin(a[0]),
|
||||
cos: a => Math.cos(a[0]),
|
||||
};
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Tok { k: string; v?: string }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
let i = 0;
|
||||
const two = ['<=', '>=', '==', '!=', '&&', '||'];
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
||||
// signal ref {ds:name}
|
||||
if (c === '{') {
|
||||
const end = src.indexOf('}', i);
|
||||
if (end < 0) throw new Error('unterminated { in expression');
|
||||
toks.push({ k: 'sig', v: src.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
// number
|
||||
if ((c >= '0' && c <= '9') || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[0-9.]/.test(src[j])) j++;
|
||||
toks.push({ k: 'num', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// identifier
|
||||
if (/[A-Za-z_]/.test(c)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
|
||||
toks.push({ k: 'ident', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// two-char operators
|
||||
const pair = src.slice(i, i + 2);
|
||||
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
||||
// single-char operators / punctuation
|
||||
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
throw new Error(`unexpected character '${c}' in expression`);
|
||||
}
|
||||
return toks;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
function parse(src: string): Node {
|
||||
const toks = tokenize(src);
|
||||
let p = 0;
|
||||
const peek = () => toks[p];
|
||||
const eat = (k?: string): Tok => {
|
||||
const t = toks[p];
|
||||
if (!t || (k && t.k !== k)) throw new Error(`expected '${k}' in expression`);
|
||||
p++;
|
||||
return t;
|
||||
};
|
||||
|
||||
function primary(): Node {
|
||||
const t = peek();
|
||||
if (!t) throw new Error('unexpected end of expression');
|
||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||
if (t.k === 'sig') {
|
||||
eat();
|
||||
const raw = t.v!;
|
||||
const idx = raw.indexOf(':');
|
||||
const ds = idx < 0 ? raw : raw.slice(0, idx);
|
||||
const name = idx < 0 ? '' : raw.slice(idx + 1);
|
||||
return { t: 'sig', ds, name };
|
||||
}
|
||||
if (t.k === 'ident') {
|
||||
eat();
|
||||
const id = t.v!;
|
||||
if (id === 'true') return { t: 'num', v: 1 };
|
||||
if (id === 'false') return { t: 'num', v: 0 };
|
||||
if (peek()?.k === '(') {
|
||||
eat('(');
|
||||
const args: Node[] = [];
|
||||
if (peek()?.k !== ')') {
|
||||
args.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
|
||||
}
|
||||
eat(')');
|
||||
return { t: 'call', fn: id, args };
|
||||
}
|
||||
return { t: 'var', name: id };
|
||||
}
|
||||
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
|
||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||
}
|
||||
|
||||
function unary(): Node {
|
||||
const t = peek();
|
||||
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
|
||||
return primary();
|
||||
}
|
||||
|
||||
function bin(next: () => Node, ops: string[]): () => Node {
|
||||
return () => {
|
||||
let a = next();
|
||||
while (peek() && ops.includes(peek().k)) {
|
||||
const op = eat().k;
|
||||
a = { t: 'bin', op, a, b: next() };
|
||||
}
|
||||
return a;
|
||||
};
|
||||
}
|
||||
|
||||
const mul = bin(unary, ['*', '/', '%']);
|
||||
const add = bin(mul, ['+', '-']);
|
||||
const cmp = bin(add, ['<', '<=', '>', '>=']);
|
||||
const eq = bin(cmp, ['==', '!=']);
|
||||
const and = bin(eq, ['&&']);
|
||||
const or = bin(and, ['||']);
|
||||
|
||||
function ternary(): Node {
|
||||
const c = or();
|
||||
if (peek()?.k === '?') {
|
||||
eat('?');
|
||||
const a = ternary();
|
||||
eat(':');
|
||||
const b = ternary();
|
||||
return { t: 'tern', c, a, b };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
const root = ternary();
|
||||
if (p < toks.length) throw new Error('trailing tokens in expression');
|
||||
return root;
|
||||
}
|
||||
|
||||
// Parsed-AST cache so the engine can re-evaluate hot expressions cheaply.
|
||||
const cache = new Map<string, Node | Error>();
|
||||
|
||||
function parseCached(src: string): Node {
|
||||
let n = cache.get(src);
|
||||
if (n === undefined) {
|
||||
try { n = parse(src); } catch (e) { n = e as Error; }
|
||||
cache.set(src, n);
|
||||
}
|
||||
if (n instanceof Error) throw n;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Evaluation ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Resolver = (ds: string, name: string) => number;
|
||||
|
||||
function ev(n: Node, R: Resolver): number {
|
||||
switch (n.t) {
|
||||
case 'num': return n.v;
|
||||
case 'sig': return R(n.ds, n.name);
|
||||
case 'var': return R('local', n.name);
|
||||
case 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0);
|
||||
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'call': {
|
||||
const fn = FUNCS[n.fn];
|
||||
if (!fn) throw new Error(`unknown function '${n.fn}'`);
|
||||
return fn(n.args.map(a => ev(a, R)));
|
||||
}
|
||||
case 'bin': {
|
||||
const a = ev(n.a, R), b = ev(n.b, R);
|
||||
switch (n.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return a / b;
|
||||
case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0;
|
||||
case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0;
|
||||
case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0;
|
||||
case '!=': return a !== b ? 1 : 0;
|
||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||
default: throw new Error(`unknown operator '${n.op}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */
|
||||
export function evalExpr(src: string, resolve: Resolver): number {
|
||||
try {
|
||||
return ev(parseCached(src), resolve);
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the expression evaluates to a truthy (nonzero, non-NaN) value. */
|
||||
export function evalBool(src: string, resolve: Resolver): boolean {
|
||||
const v = evalExpr(src, resolve);
|
||||
return !isNaN(v) && v !== 0;
|
||||
}
|
||||
|
||||
/** Collect every signal/local reference an expression reads, for subscription. */
|
||||
export function collectRefs(src: string): RefLite[] {
|
||||
const out: RefLite[] = [];
|
||||
let root: Node;
|
||||
try { root = parseCached(src); } catch { return out; }
|
||||
const seen = new Set<string>();
|
||||
const add = (ds: string, name: string) => {
|
||||
const k = `${ds}\0${name}`;
|
||||
if (!seen.has(k)) { seen.add(k); out.push({ ds, name }); }
|
||||
};
|
||||
const walk = (n: Node) => {
|
||||
switch (n.t) {
|
||||
case 'sig': add(n.ds, n.name); break;
|
||||
case 'var': add('local', n.name); break;
|
||||
case 'un': walk(n.a); break;
|
||||
case 'bin': walk(n.a); walk(n.b); break;
|
||||
case 'tern': walk(n.c); walk(n.a); walk(n.b); break;
|
||||
case 'call': n.args.forEach(walk); break;
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate an expression; returns an error message or null if it parses. */
|
||||
export function checkExpr(src: string): string | null {
|
||||
if (!src.trim()) return null;
|
||||
try { parse(src); return null; } catch (e) { return e instanceof Error ? e.message : String(e); }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user