Compare commits

...

10 Commits

Author SHA1 Message Date
Martino Ferrari 914108e575 Plot fix 2026-06-19 07:57:42 +02:00
Martino Ferrari ba836f00e6 Add widget-control logic action and multi-column CSV / multi-field dialogs
Extend the panel logic editor with two capabilities:

- action.widget: drive a panel widget by id from a flow — enable/disable,
  show/hide, and (for plot widgets) clear, pause, or resume the live plot.
  Wired via a per-widget command store consumed by a Canvas WidgetView wrapper
  (disable overlay / hide) and PlotWidget (pause/clear), reset on panel unmount.
- action.export now emits multiple named arrays as CSV columns with per-column
  labels and an alignment mode (common/any/interpolate); dialogs support asking
  for and displaying multiple values per dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:51:18 +02:00
Martino Ferrari afefba3184 Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:27:35 +02:00
Martino Ferrari aba394b84d Major changes: logic add to panel, local variables, panel histor, users management... 2026-06-18 17:37:04 +02:00
Martino Ferrari 71430bc3b0 Wworking on improving the tool 2026-05-21 07:41:56 +02:00
Martino Ferrari 6ff8fb5c25 Improved perfs 2026-05-12 10:16:48 +02:00
Martino Ferrari 912ecdd9ed Improved UI 2026-05-06 15:55:45 +02:00
Martino Ferrari 0a5a85e4c4 working epics ioc and tested 2026-05-04 21:13:36 +02:00
Martino Ferrari 90669c5fd6 Initial working fully go release 2026-04-30 23:01:01 +02:00
Martino Ferrari 6e51ffc5e1 Initial go port of epics 2026-04-28 00:09:22 +02:00
122 changed files with 24049 additions and 1064 deletions
+7
View File
@@ -5,3 +5,10 @@ interfaces/
synthetic.json synthetic.json
uopi.toml uopi.toml
*.local.toml *.local.toml
resources
.claude
.github
.goreleaser.yaml
.iocsh_history
go.work.sum
*.log
+25 -105
View File
@@ -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 # # 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) \ $(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
tools/buildfrontend/main.go 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 # # 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 FRONTEND_OUT := web/dist/main.js
BINARY := dist/uopi BINARY := dist/uopi
CATOOLS := dist/catools
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Top-level targets # # Top-level targets #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
all: $(BINARY) all: $(BINARY) $(CATOOLS)
frontend: $(FRONTEND_OUT) frontend: $(FRONTEND_OUT)
backend: $(BINARY) backend: $(BINARY) $(CATOOLS)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Build rules # # Build rules #
@@ -36,127 +39,40 @@ $(FRONTEND_OUT): $(FRONTEND_SRCS)
@mkdir -p web/dist @mkdir -p web/dist
go run ./tools/buildfrontend/main.go 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) $(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
@mkdir -p dist @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. # CA diagnostic tools (caget/caput/cainfo/camonitor equivalents)
# $(CATOOLS): $(GO_SRCS)
# 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)"
@mkdir -p dist @mkdir -p dist
CGO_ENABLED=1 \ CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
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)"
# Unstripped binary for debugging # Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
backend-debug: $(FRONTEND_OUT) backend-debug: $(FRONTEND_OUT)
@mkdir -p dist @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: # Fully static, pure-Go CA client, no external library dependencies.
# # Cross-compile: make release RELEASE_OS=linux RELEASE_ARCH=arm64
# 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
RELEASE_OS ?= linux RELEASE_OS ?= linux
RELEASE_ARCH ?= amd64 RELEASE_ARCH ?= amd64
RELEASE_DIR := dist/release RELEASE_DIR := dist/release
EPICS_LIBDIR := $(EPICS_BASE)/lib/$(EPICS_ARCH)
# Fully static, no CGO — the most portable build possible.
release: $(FRONTEND_OUT) release: $(FRONTEND_OUT)
@mkdir -p $(RELEASE_DIR) @mkdir -p $(RELEASE_DIR)
CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \ CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \
go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi
@echo "Built: $(RELEASE_DIR)/uopi (fully static, no EPICS)" @echo "Built: $(RELEASE_DIR)/uopi (static, pure-Go CA)"
# 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Dev / CI # # Dev / CI #
@@ -164,10 +80,12 @@ release-epics: $(FRONTEND_OUT)
test: test:
go test ./... go test ./...
cd pkg/ca && go test ./...
# Run tests with the race detector enabled. # Run tests with the race detector enabled.
race: race:
go test -race ./... go test -race ./...
cd pkg/ca && go test -race ./...
# Run all benchmarks and print memory allocations. # Run all benchmarks and print memory allocations.
bench: bench:
@@ -179,5 +97,7 @@ lint:
run: $(BINARY) run: $(BINARY)
$(BINARY) $(BINARY)
catools: $(CATOOLS)
clean: clean:
rm -rf dist/ web/dist/ rm -rf dist/ web/dist/
+31 -4
View File
@@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
| Feature | Details | | 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 | | **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 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 | | **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 | | **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy | | **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
| **Observability** | Prometheus-format metrics at `/metrics` | | **Observability** | Prometheus-format metrics at `/metrics` |
@@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
```toml ```toml
[server] [server]
listen = ":8080" 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] [datasource.epics]
enabled = false # requires build with -tags 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/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface | | `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/{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/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` | `/metrics` | Prometheus-format server metrics |
| `GET` | `/healthz` | Health check | | `GET` | `/healthz` | Health check |
+327
View File
@@ -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)
}
+253
View File
@@ -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
View File
@@ -11,11 +11,15 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config" "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/epics"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web" "github.com/uopi/uopi/web"
@@ -48,6 +52,12 @@ func main() {
os.Exit(1) 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") webFS, err := fs.Sub(web.FS, "dist")
if err != nil { if err != nil {
log.Error("failed to sub web dist", "err", err) 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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() 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). // Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
// Enabled by default; disable via [datasource.stub] enabled = false in config. // 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 Channel Access data source.
// epics.Available() returns false when built without the tag. // 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() { 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 { if err := ds.Connect(ctx); err != nil {
log.Error("epics connect", "err", err) log.Error("epics connect", "err", err)
} else { } 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 { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+211 -66
View File
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
## 2. Users and Roles ## 2. Users and Roles
| Role | Description | 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
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts | unproxied/LAN deployments. There is no login page inside the application.
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
| Administrator | Manages server configuration, data sources, and saved interfaces |
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. The default mode when opening the application.
**Interface list pane (left, collapsible)** **Interface list pane (left, collapsible, resizable)**
- Displays all interfaces saved on the server, grouped into a tree by folder. - Displays all interfaces saved on the server.
- Right-click on an interface: options to open in Edit mode or clone it. - Right-click on an interface: options to open in Edit mode or clone it.
- "New interface" button opens Edit mode with a blank canvas. - "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)** **HMI canvas (center)**
- Renders the selected interface as a live, interactive panel. - Renders the selected interface as a live, interactive panel.
- Widgets display real-time data; controls (set-value, buttons) are active. - 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. - No drag, resize, or layout operations are possible in this mode.
- Right-clicking any widget opens a context menu: - 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. - **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** **Top toolbar**
- Show/hide interface list pane. - 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). - **⏱ History** button: toggle historical time navigation bar.
- "Live" button: return to real-time data after historical navigation. - **⚙ 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 ### 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)** **Signal tree pane (left, resizable and collapsible)**
- Shows all signals known to each connected data source. - Shows all signals known to each connected data source.
- Sources are shown as top-level nodes; signals are nested within. - Sources are shown as top-level nodes; signals are nested within.
- User can add custom entries: - User can add custom entries:
- For EPICS: manually enter a PV name. - For EPICS: manually enter a PV name.
- For Synthetic: define a new synthetic signal (see §5.2). - For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
- Filter/search box to narrow the list. - Filter/search box to narrow the list.
- Synthetic signals show an edit (✎) button to reopen the wizard.
**Widget canvas (center)** **Center area — Layout / Logic tabs**
- Free-form canvas where widgets can be placed at arbitrary pixel positions. - **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
- Background grid with optional snap-to-grid. 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. - Appears when one or more widgets are selected.
- Displays and edits all options for the selected widget (see §4). - 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** **Top toolbar**
- Show/hide signal pane. - Show/hide signal pane / properties pane.
- Show/hide properties pane.
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z). - Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
- Import / Export interface to/from local XML file.
- Save interface to server. - Save interface to server.
- Load interface from server. - Close (return to View mode).
- Export interface to local XML file. - Zoom control (A / % / A+).
- Import interface from local XML file.
- "Add text" tool — inserts a static text label. ### 3.3 Plot Panels (Split Layout)
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
- "Add link" tool — inserts a button that opens another interface. 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 ### 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 ### 4.2 Selecting Widgets
@@ -104,8 +163,7 @@ When multiple widgets are selected:
- An align/distribute toolbar appears above the canvas with: - An align/distribute toolbar appears above the canvas with:
- Align left / center horizontal / right. - Align left / center horizontal / right.
- Align top / center vertical / bottom. - Align top / center vertical / bottom.
- Distribute evenly — by center spacing (horizontal/vertical). - Distribute evenly (horizontal/vertical).
- Distribute evenly — by gap size (horizontal/vertical).
### 4.4 Widget Catalogue ### 4.4 Widget Catalogue
@@ -115,7 +173,7 @@ When multiple widgets are selected:
| Gauge | numeric scalar | Circular or arc gauge with configurable range | | Gauge | numeric scalar | Circular or arc gauge with configurable range |
| Vertical bar | numeric scalar | Vertical level indicator | | Vertical bar | numeric scalar | Vertical level indicator |
| Horizontal bar | numeric scalar | Horizontal 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 | | LED | boolean / numeric | Coloured indicator with configurable condition and label |
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions | | Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
| Button | writable | Sends a fixed value or command on click | | Button | writable | Sends a fixed value or command on click |
@@ -140,67 +198,153 @@ When multiple widgets are selected:
Common to all: Common to all:
- Label text, font size, text colour. - Label text, font size, text colour.
- Position (X, Y) and size (W, H) — editable numerically. - 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: Per type:
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label. - **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. - **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), time window duration, legend position, colour per signal, line style. - **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**: input type (numeric / string / enum), confirmation prompt toggle. - **Set value**: no special options — enum mode is detected automatically from signal metadata.
- **Link**: target interface name. - **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. Data Sources
### 5.1 EPICS ### 5.1 EPICS
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA). - Connects to an EPICS environment via Channel Access (CA).
- 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. - 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.
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable. - Uses `ca_add_event` monitors — no polling.
- 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 is configured, the server can satisfy historical data requests from the toolbar time navigator.
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
### 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):** **Two authoring surfaces:**
- Arithmetic: gain, offset, add, subtract, multiply, divide. - **Wizard** — for the common single-input case: name the signal, pick an input and a
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral. processing node, set parameters, Create.
- FFT, inverse FFT. - **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
- Peak detection, threshold crossing. DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
- Custom formula: inline expression (`a * sin(b) + c`).
- Lua script block: arbitrary Lua code with access to input values and state.
**User workflow:** **Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
1. Click "New synthetic signal" in the signal tree. that created it), *user*, or *global* (shared with everyone).
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). The dialogs are resizable and default to a width that accommodates the Lua editor.
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. **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 (18) | 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. - 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. - 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: **Identity** is resolved per request from the trusted proxy header, falling back to
- The top toolbar shows a date/time picker. `default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level,
- Selecting a past time replays data from the archive into all widgets. group memberships and whether they may edit logic; the UI hides affordances accordingly.
- 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. **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 | | 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 | | Concurrent clients | ≥ 20 simultaneous browser clients |
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate | | Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
| Frontend responsiveness | 60 fps canvas rendering during live updates | | 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 50250% |
| Plot buffer | 200,000 samples per signal retained in-browser |
+86
View File
@@ -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.
+253 -104
View File
@@ -20,31 +20,31 @@
| Package | Purpose | | Package | Purpose |
| ---------------------------- | ------------------------------------------------------ | | ---------------------------- | ------------------------------------------------------ |
| `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) | | `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 | | `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 | | `encoding/xml` (stdlib) | Interface file serialisation |
| `net/http` (stdlib) | HTTP server and static file serving | | `net/http` (stdlib) | HTTP server and static file serving |
### 1.2 Frontend — Svelte + TypeScript ### 1.2 Frontend — Preact + TypeScript
**Rationale:** **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. - Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival. - 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. - 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 | | 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) | | `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots | | `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) | | `uplot.css` | uPlot default stylesheet |
| `svelte-konva` | Svelte bindings for Konva |
**Intentionally excluded:** React, Vue, WebGPU, jQuery. **Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
--- ---
@@ -52,36 +52,49 @@
``` ```
uopi/ uopi/
├── cmd/uopi/ # main package — CLI flags, wiring ├── cmd/uopi/ # main package — CLI flags, wiring
├── internal/ ├── internal/
│ ├── server/ # HTTP + WebSocket handlers │ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients │ ├── broker/ # signal fan-out to clients
│ ├── datasource/ │ ├── datasource/
│ │ ├── iface.go # DataSource interface │ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA/PVA implementation │ │ ├── epics/ # EPICS CA implementation (CGo)
│ │ └── synthetic/ # synthetic signal engine │ │ └── synthetic/ # synthetic signal engine + DSP bridge
│ ├── lua/ # Lua sandbox helpers │ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
│ ├── dsp/ # DSP functions (wraps gonum + custom) │ ├── storage/ # interface XML read/write
── storage/ # interface XML read/write ── api/ # REST handler functions
│ └── api/ # REST handler functions ├── web/
├── web/ # Svelte source │ ├── embed.go # //go:embed dist — exports FS to Go
│ ├── src/ │ ├── src/ # TypeScript/TSX source (Preact)
│ │ ├── lib/ │ │ ├── lib/
│ │ │ ├── ws.ts # WebSocket client + subscription manager │ │ │ ├── ws.ts # WebSocket client + subscription manager
│ │ │ ├── stores.ts # Svelte stores for signal values │ │ │ ├── stores.ts # signal value + metadata stores
│ │ │ ├── widgets/ # one .svelte file per widget type │ │ │ ├── types.ts # shared TypeScript interfaces
│ │ │ ── editor/ # edit-mode canvas, toolbar, properties pane │ │ │ ── xml.ts # interface XML parse/serialize
│ │ ├── routes/ │ │ │ └── format.ts # value formatting helpers
│ │ │ ├── +page.svelte # view mode │ │ ├── widgets/ # one .tsx file per widget type
│ │ │ └── edit/+page.svelte # edit mode │ │ ├── App.tsx # top-level component, mode routing
│ │ ── app.html │ │ ── ViewMode.tsx # view mode layout + tabs
│ ├── package.json │ ├── EditMode.tsx # edit mode layout + toolbar
└── vite.config.ts │ ├── Canvas.tsx # live HMI canvas (view mode)
├── docs/ # specs, work plan │ │ ├── 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 ├── CLAUDE.md
└── README.md └── README.md
``` ```
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
--- ---
## 3. Backend Architecture ## 3. Backend Architecture
@@ -173,22 +186,40 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
Base path: `/api/v1` Base path: `/api/v1`
| Method | Path | Description | | Method | Path | Description |
| ------ | ------------------------- | -------------------------------------------- | | --------------- | ------------------------------------- | ------------------------------------------------- |
| GET | `/datasources` | List connected data sources and their status | | GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
| GET | `/signals?ds=epics` | List signals for a data source | | GET | `/datasources` | List connected data sources and their status |
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal | | GET | `/signals?ds=epics` | List signals for a data source |
| GET | `/interfaces` | List saved interfaces | | GET | `/signals/search?q=` | Search signals across all sources |
| POST | `/interfaces` | Create a new interface (body: XML) | | GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
| GET | `/interfaces/:id` | Download interface XML | | GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
| PUT | `/interfaces/:id` | Update interface XML | | GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
| DELETE | `/interfaces/:id` | Delete interface | | POST | `/interfaces/reorder` | Reorder panels / move between folders |
| POST | `/interfaces/:id/clone` | Clone an interface | | 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 ### 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. - Uses CGo bindings to EPICS Base `libca` (Channel Access).
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it. - 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). - 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. - `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`). - 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. - 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. - 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. - Definitions are stored in a configurable JSON/TOML file alongside server configuration.
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table. - The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
**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` (18) | 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 ### 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 ```xml
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z"> <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="yMin" value="auto"/>
<option key="yMax" value="auto"/> <option key="yMax" value="auto"/>
<option key="timeWindow" value="60"/> <option key="timeWindow" value="60"/>
<option key="legend" value="bottom"/>
</widget> </widget>
<widget id="w2" type="led" x="50" y="50" w="80" h="80"> <widget id="w2" type="led" x="50" y="50" w="80" h="80">
<signal ds="epics" name="EPICS:STATUS"/> <signal ds="epics" name="EPICS:STATUS"/>
@@ -226,51 +281,117 @@ Interfaces are stored as XML files in a configurable directory on the server.
</interface> </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. Frontend Architecture
### 4.1 WebSocket Client (`ws.ts`) ### 4.1 WebSocket Client (`lib/ws.ts`)
- Singleton WebSocket connection, reconnects with exponential back-off. - Singleton WebSocket connection, reconnects with exponential back-off.
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message. - 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 ```typescript
// One writable store per subscribed signal // One nanostores atom per subscribed signal
const signalStores = new Map<string, Writable<SignalValue>>(); 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. - Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
- A `Transformer` node provides resize handles and enforces minimum sizes. - 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 `stage.getPointerPosition()`. - 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 a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100). - Undo/redo uses an array of past interface snapshots (max depth 50).
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry. - 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`. - Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
2. Receives reactive updates and re-renders only its own DOM subtree. - 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. - In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed. - In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`. 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 ### 5.1 Backend
```makefile ```bash
# Build static binary (requires EPICS base installed or cross-compiled libca) # Full build (frontend then backend)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ make all
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
# Run tests # Backend only (frontend must already be built)
go test ./... make backend
# Run a single test # All tests
make test
# Single Go test
go test ./internal/broker/... -run TestFanOut 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`. EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
### 5.2 Frontend ### 5.2 Frontend
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
```bash ```bash
cd web make frontend
npm install # or equivalently:
npm run dev # dev server at http://localhost:5173 (proxies /api to backend) go generate ./web/...
npm run build # outputs to web/dist/
npm run check # svelte-check type checking
npm run lint # eslint + prettier
``` ```
### 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 ```makefile
.PHONY: all frontend backend clean .PHONY: all frontend backend clean
@@ -313,20 +442,19 @@ A `Makefile` at the repo root:
all: frontend backend all: frontend backend
frontend: frontend:
cd web && npm ci && npm run build go run ./tools/buildfrontend
backend: frontend backend:
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
test: test:
go test ./... go test ./...
cd web && npm run check
clean: clean:
rm -rf dist/ web/dist/ 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" listen = ":8080"
storage_dir = "./interfaces" 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] [datasource.epics]
enabled = true enabled = true
ca_addr_list = "" # EPICS_CA_ADDR_LIST override ca_addr_list = "" # EPICS_CA_ADDR_LIST override
archive_url = "" # EPICS Archive Appliance URL archive_url = "" # EPICS Archive Appliance URL
channel_finder_url = "" # EPICS Channel Finder URL
[datasource.synthetic] [datasource.synthetic]
enabled = true 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 | | Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs | | 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 | | Lua sandbox | Unit tests for sandbox isolation and API surface |
| REST API | `httptest` integration tests | | REST API | `httptest` integration tests |
| WebSocket protocol | Integration tests with a test client | | WebSocket protocol | Integration tests with a test client |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) | | 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. - 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. - 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. - 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) ## 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). - TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
- Windows or macOS server binary. - Windows or macOS server binary.
- Mobile-optimised frontend layout. - Mobile-optimised frontend layout.
+7
View File
@@ -0,0 +1,7 @@
go 1.26.2
use (
.
./pkg/ca
./pkg/pva
)
+189
View File
@@ -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
}
+35
View File
@@ -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)
}
}
}
+884 -17
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -12,9 +12,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
) )
@@ -38,9 +41,19 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil { if err != nil {
t.Fatal("storage.New:", err) 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() 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) srv := httptest.NewServer(mux)
return srv, func() { return srv, func() {
@@ -396,7 +409,7 @@ func TestStorageValidateID(t *testing.T) {
} }
// Create a real interface first to get a valid ID. // 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 { if err != nil {
t.Fatal("create:", err) t.Fatal("create:", err)
} }
@@ -416,7 +429,7 @@ func TestStorageValidateID(t *testing.T) {
if _, err := store.Get(bad); err == nil { if _, err := store.Get(bad); err == nil {
t.Errorf("Get(%q) should have failed, got nil error", bad) 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) t.Errorf("Update(%q) should have failed", bad)
} }
if err := store.Delete(bad); err == nil { if err := store.Delete(bad); err == nil {
+69 -11
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
) )
@@ -41,18 +42,38 @@ type Broker struct {
sources map[string]datasource.DataSource sources map[string]datasource.DataSource
subs map[SignalRef]*signalSub subs map[SignalRef]*signalSub
log *slog.Logger 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. // New creates a Broker whose upstream subscriptions are bound to ctx.
// Cancel ctx (or the parent context passed to main) to shut everything down. // Cancel ctx (or the parent context passed to main) to shut everything down.
func New(ctx context.Context, log *slog.Logger) *Broker { func New(ctx context.Context, log *slog.Logger, opts ...Option) *Broker {
return &Broker{ b := &Broker{
ctx: ctx, ctx: ctx,
sources: make(map[string]datasource.DataSource), sources: make(map[string]datasource.DataSource),
subs: make(map[SignalRef]*signalSub), subs: make(map[SignalRef]*signalSub),
log: log, log: log,
} }
for _, opt := range opts {
opt(b)
}
return b
} }
// Register adds a DataSource to the broker. Must be called before Subscribe. // 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. // fanOut reads values from rawCh and dispatches them to all registered clients.
// It exits when sub.done is closed or rawCh is closed. // 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) { 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 { for {
select { select {
case v, ok := <-rawCh: case v, ok := <-rawCh:
@@ -177,15 +227,23 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
return return
} }
update := Update{Ref: ref, Value: v} update := Update{Ref: ref, Value: v}
sub.mu.RLock() // Rate-limit: if an interval is configured and the last delivery
for ch := range sub.clients { // was recent, hold this update as pending (coalesce).
select { if maxInterval > 0 && !lastSent.IsZero() && time.Since(lastSent) < maxInterval {
case ch <- update: pending = &update
default: continue
// slow consumer: drop rather than block }
} 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: case <-sub.done:
return return
+269
View File
@@ -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)
}
}
+62
View File
@@ -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) { func TestMultipleSignals(t *testing.T) {
b, cancel := newBroker(t) b, cancel := newBroker(t)
defer cancel() defer cancel()
+80 -6
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
@@ -11,16 +12,57 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"` 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 { type ServerConfig struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"` 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 { type DatasourceConfig struct {
Stub StubConfig `toml:"stub"` Stub StubConfig `toml:"stub"`
EPICS EPICSConfig `toml:"epics"` EPICS EPICSConfig `toml:"epics"`
PVA PVAConfig `toml:"pva"`
Synthetic SyntheticConfig `toml:"synthetic"` Synthetic SyntheticConfig `toml:"synthetic"`
} }
@@ -29,10 +71,18 @@ type StubConfig struct {
} }
type EPICSConfig struct { type EPICSConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"` CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"` ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_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 { type SyntheticConfig struct {
@@ -48,6 +98,7 @@ func Default() Config {
Datasource: DatasourceConfig{ Datasource: DatasourceConfig{
Stub: StubConfig{Enabled: true}, Stub: StubConfig{Enabled: true},
EPICS: EPICSConfig{Enabled: true}, EPICS: EPICSConfig{Enabled: true},
PVA: PVAConfig{Enabled: true},
Synthetic: SyntheticConfig{Enabled: true}, Synthetic: SyntheticConfig{Enabled: true},
}, },
} }
@@ -76,6 +127,20 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" { if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
cfg.Server.StorageDir = 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 != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
@@ -85,6 +150,15 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" { if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
cfg.Datasource.EPICS.ChannelFinderURL = 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 { func env(key string) string {
+124
View File
@@ -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)
}
}
+161
View File
@@ -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
}
+92
View File
@@ -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)
}
}
}
+688
View File
@@ -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)
})
}
+527
View File
@@ -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 ""
}
+86
View File
@@ -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)
}
}
+115
View File
@@ -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())
}
}
+65
View File
@@ -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]
}
+111
View File
@@ -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()
}
+50 -2
View File
@@ -1,5 +1,3 @@
//go:build epics
package epics package epics
import ( import (
@@ -9,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
@@ -119,6 +118,55 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
return out, nil 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 // coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
// as float64, string, bool, []interface{}, or nil) into one of the types // as float64, string, bool, []interface{}, or nil) into one of the types
// accepted by datasource.Value.Data. // accepted by datasource.Value.Data.
+67
View File
@@ -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")
}
}
+106 -15
View File
@@ -26,6 +26,7 @@ import "C"
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -66,10 +67,14 @@ type caChannel struct {
// EPICS is the Channel Access data source. // EPICS is the Channel Access data source.
type EPICS struct { type EPICS struct {
caAddrList string caAddrList string
archiveURL 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 // because the C type (ca_client_context *) is opaque. Every goroutine
// that calls CA functions must call caAttachContext(caCtx) first, because // that calls CA functions must call caAttachContext(caCtx) first, because
// Go goroutines can run on any OS thread and CA contexts are thread-local. // Go goroutines can run on any OS thread and CA contexts are thread-local.
@@ -85,12 +90,17 @@ type EPICS struct {
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to // 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 // rely on the environment). archiveURL is the base URL of an EPICS Archive
// Appliance instance for history queries (may be empty). // 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{ return &EPICS{
caAddrList: caAddrList, caAddrList: caAddrList,
archiveURL: archiveURL, archiveURL: archiveURL,
channels: make(map[string]*caChannel), cfURL: cfURL,
metadata: make(map[string]datasource.Metadata), 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. // Save the context so goroutines on other OS threads can attach to it.
e.caCtx = C.caCurrentContext() 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 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. // attachCAContext attaches the saved CA context to the calling OS thread.
// Must be called at the top of every goroutine that uses CA functions, // Must be called at the top of every goroutine that uses CA functions,
// because Go goroutines can be scheduled onto any OS thread. // 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. // fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata { func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
// In EPICS, write access is governed by CA security (host/user rules), not // ca_write_access returns 1 if CA security permits puts from this client.
// by the field type. Default to writable=true; the IOC will reject puts // This reflects the IOC's actual security policy (host/user ACLs), so we
// that violate its security policy. // use it directly rather than defaulting to true and relying on put failures.
meta := datasource.Metadata{Name: name, Writable: true} meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}
fieldType := C.ca_field_type(chid) fieldType := C.ca_field_type(chid)
count := C.ca_element_count(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. // 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) { func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
slog.Debug("epics: listing signals", "count", len(e.metadata))
// Start with all fully-fetched metadata entries. // Start with all fully-fetched metadata entries.
out := make([]datasource.Metadata, 0, len(e.channels)) out := make([]datasource.Metadata, 0, len(e.channels))
seen := make(map[string]bool, len(e.metadata)) 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. // Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode), // 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. // 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 { func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
e.attachCAContext() e.attachCAContext()
@@ -581,3 +648,27 @@ func nativeTimeType(chid C.chid) C.chtype {
return C.DBR_TIME_DOUBLE 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)
}
}
+466 -11
View File
@@ -1,17 +1,472 @@
//go:build !epics //go:build !epics
// Package epics provides an EPICS Channel Access data source. // Package epics provides an EPICS Channel Access data source for uopi using a
// When built without the "epics" build tag (i.e. without CGo and EPICS Base), // pure-Go CA implementation (no CGo, no EPICS Base installation required).
// this stub is compiled instead, so that the rest of the codebase can call // When built WITH the "epics" build tag, the CGo-based implementation in
// epics.Available() to decide whether to register the data source. // epics.go is used instead; that version requires CGo and a working EPICS Base.
package epics 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. ca "github.com/uopi/goca"
// It always returns false when built without the "epics" build tag. "github.com/uopi/goca/proto"
func Available() bool { return false } "github.com/uopi/uopi/internal/datasource"
)
// New is a placeholder that returns nil when EPICS support is not compiled in. // Available always returns true for the pure-Go build.
// Callers must check Available() before calling New(). func Available() bool { return true }
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
// -------------------------------------------------------------------------- //
// 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))
}
+2
View File
@@ -60,6 +60,8 @@ type Metadata struct {
DriveHigh float64 DriveHigh float64
EnumStrings []string // non-nil only for TypeEnum EnumStrings []string // non-nil only for TypeEnum
Writable bool Writable bool
Properties map[string]string // Channel Finder properties
Tags []string // Channel Finder tags
} }
// CancelFunc cancels a subscription started by DataSource.Subscribe. // CancelFunc cancels a subscription started by DataSource.Subscribe.
+365
View File
@@ -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
}
+48 -5
View File
@@ -5,6 +5,7 @@ package stub
import ( import (
"context" "context"
"fmt"
"math" "math"
"math/rand/v2" "math/rand/v2"
"sync" "sync"
@@ -13,11 +14,12 @@ import (
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
) )
const updateInterval = 100 * time.Millisecond // 10 Hz const updateInterval = 100 * time.Millisecond // 10 Hz default
type signalDef struct { type signalDef struct {
meta datasource.Metadata meta datasource.Metadata
fn func(t time.Time) any interval time.Duration // 0 → updateInterval
fn func(t time.Time) any
} }
// Stub is a data source that emits canned signals for development and testing. // Stub is a data source that emits canned signals for development and testing.
@@ -96,6 +98,41 @@ func New() *Stub {
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
}) })
s.values["setpoint"] = 25.0 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 return s
} }
@@ -127,16 +164,22 @@ func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadat
return d.meta, nil 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) { func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
def, ok := s.signals[signal] def, ok := s.signals[signal]
if !ok { if !ok {
return nil, datasource.ErrNotFound return nil, datasource.ErrNotFound
} }
interval := def.interval
if interval == 0 {
interval = updateInterval
}
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
go func() { go func() {
ticker := time.NewTicker(updateInterval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
+210
View File
@@ -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 Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides 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. // InputRef names one upstream signal used as input to the pipeline.
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "derivative": case "derivative":
return &dsp.DerivativeNode{}, nil return &dsp.DerivativeNode{}, nil
case "integrate":
return &dsp.IntegrateNode{}, nil
case "clamp": case "clamp":
return &dsp.ClampNode{ return &dsp.ClampNode{
Min: floatParam(p, "min"), Min: floatParam(p, "min"),
@@ -99,6 +102,16 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "expr": case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil 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": case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
return out, nil 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. // GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) { func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock() s.mu.RLock()
@@ -286,6 +302,52 @@ func (s *Synthetic) RemoveSignal(name string) error {
return nil 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). // GetDefs returns a copy of all current signal definitions (for the REST API).
func (s *Synthetic) GetDefs() []SignalDef { func (s *Synthetic) GetDefs() []SignalDef {
s.mu.RLock() s.mu.RLock()
+27
View File
@@ -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 != ""
}
+240 -33
View File
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
return 0, nil 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 ─────────────────────────────────────────────────────────────────
// ClampNode clamps the output to [Min, Max]. // 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 return result, nil
} }
// exprParser is a recursive-descent parser for simple arithmetic expressions. // exprParser is a recursive-descent parser for arithmetic expressions.
// Grammar: // Grammar:
// //
// expr = term (('+' | '-') term)* // 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 // 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 { type exprParser struct {
src string src string
pos int pos int
@@ -328,7 +365,7 @@ func (p *exprParser) parseExpr() (float64, error) {
} }
func (p *exprParser) parseTerm() (float64, error) { func (p *exprParser) parseTerm() (float64, error) {
left, err := p.parseFactor() left, err := p.parsePower()
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -338,7 +375,7 @@ func (p *exprParser) parseTerm() (float64, error) {
break break
} }
p.pos++ p.pos++
right, err := p.parseFactor() right, err := p.parsePower()
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -354,6 +391,136 @@ func (p *exprParser) parseTerm() (float64, error) {
return left, nil return left, nil
} }
// parsePower handles right-associative exponentiation: a^b^c = a^(b^c).
// Unary minus is consumed here so that -a^2 = -(a^2), not (-a)^2 — matching
// standard mathematical and Python/Julia precedence rules.
func (p *exprParser) parsePower() (float64, error) {
// Collect leading unary minuses; an even count cancels out.
sign := 1.0
p.skipSpaces()
for p.pos < len(p.src) && p.src[p.pos] == '-' {
p.pos++
sign = -sign
p.skipSpaces()
}
base, err := p.parseCall()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == '^' {
p.pos++
exp, err := p.parsePower() // right-associative recursion
if err != nil {
return 0, err
}
return sign * math.Pow(base, exp), nil
}
return sign * base, nil
}
// parseCall handles function calls like exp(a) or pow(a, 2).
func (p *exprParser) parseCall() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.src) {
return 0, errors.New("unexpected end of expression")
}
// Try to read an identifier (function name or variable).
if p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' {
start := p.pos
for p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' ||
p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' ||
p.src[p.pos] >= '0' && p.src[p.pos] <= '9' ||
p.src[p.pos] == '_') {
p.pos++
}
name := p.src[start:p.pos]
// Check for function call syntax: name(...)
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == '(' {
p.pos++ // consume '('
arg1, err := p.parseExpr()
if err != nil {
return 0, err
}
// Optional second argument for two-argument functions.
var arg2 float64
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == ',' {
p.pos++
arg2, err = p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
}
if p.pos >= len(p.src) || p.src[p.pos] != ')' {
return 0, fmt.Errorf("missing ')' after %s(...)", name)
}
p.pos++ // consume ')'
switch name {
case "exp":
return math.Exp(arg1), nil
case "log", "ln":
return math.Log(arg1), nil
case "log2":
return math.Log2(arg1), nil
case "log10":
return math.Log10(arg1), nil
case "sqrt":
return math.Sqrt(arg1), nil
case "abs":
return math.Abs(arg1), nil
case "sin":
return math.Sin(arg1), nil
case "cos":
return math.Cos(arg1), nil
case "tan":
return math.Tan(arg1), nil
case "asin":
return math.Asin(arg1), nil
case "acos":
return math.Acos(arg1), nil
case "atan":
return math.Atan(arg1), nil
case "atan2":
return math.Atan2(arg1, arg2), nil
case "pow":
return math.Pow(arg1, arg2), nil
case "floor":
return math.Floor(arg1), nil
case "ceil":
return math.Ceil(arg1), nil
case "round":
return math.Round(arg1), nil
case "min":
return math.Min(arg1, arg2), nil
case "max":
return math.Max(arg1, arg2), nil
default:
return 0, fmt.Errorf("unknown function %q", name)
}
}
// Not a function call — must be a single-letter variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
}
return val, nil
}
return p.parseFactor()
}
func (p *exprParser) parseFactor() (float64, error) { func (p *exprParser) parseFactor() (float64, error) {
p.skipSpaces() p.skipSpaces()
if p.pos >= len(p.src) { if p.pos >= len(p.src) {
@@ -362,7 +529,7 @@ func (p *exprParser) parseFactor() (float64, error) {
ch := p.src[p.pos] ch := p.src[p.pos]
// Parenthesised subexpression // Parenthesised subexpression.
if ch == '(' { if ch == '(' {
p.pos++ p.pos++
val, err := p.parseExpr() val, err := p.parseExpr()
@@ -377,35 +544,13 @@ func (p *exprParser) parseFactor() (float64, error) {
return val, nil return val, nil
} }
// Unary minus // Number (including optional decimal point and exponent notation).
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)
if ch >= '0' && ch <= '9' || ch == '.' { if ch >= '0' && ch <= '9' || ch == '.' {
start := p.pos 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++ p.pos++
} }
f, err := strconv.ParseFloat(p.src[start:p.pos], 64) 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) 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 (18)
}
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 ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM. // LuaNode runs a Lua script in a sandboxed gopher-lua VM.
+69
View File
@@ -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)
}
}
+417
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+49 -4
View File
@@ -5,12 +5,16 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "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. // 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. // 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() mux := http.NewServeMux()
// Health check // Health check
@@ -33,13 +37,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
}) })
// WebSocket endpoint // 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 // Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
// REST API // REST API — registered on a dedicated mux so it can be wrapped with the
api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix) // 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) // Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS)) 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. // Start listens and serves until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
s.log.Info("listening", "addr", s.httpServer.Addr) s.log.Info("listening", "addr", s.httpServer.Addr)
+56 -17
View File
@@ -6,11 +6,13 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
@@ -64,15 +66,17 @@ type outMsg struct {
} }
type metaPayload struct { type metaPayload struct {
Type string `json:"type"` Type string `json:"type"`
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"` DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"` DisplayHigh float64 `json:"displayHigh"`
DriveLow float64 `json:"driveLow,omitempty"` DriveLow float64 `json:"driveLow,omitempty"`
DriveHigh float64 `json:"driveHigh,omitempty"` DriveHigh float64 `json:"driveHigh,omitempty"`
EnumStrings []string `json:"enumStrings,omitempty"` EnumStrings []string `json:"enumStrings,omitempty"`
Writable bool `json:"writable"` Writable bool `json:"writable"`
Properties map[string]string `json:"properties,omitempty"`
Tags []string `json:"tags,omitempty"`
} }
type histPoint struct { type histPoint struct {
@@ -85,9 +89,21 @@ type histPoint struct {
type wsHandler struct { type wsHandler struct {
broker *broker.Broker broker *broker.Broker
log *slog.Logger 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) { 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{ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands. // Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true, InsecureSkipVerify: true,
@@ -105,8 +121,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &wsClient{ c := &wsClient{
conn: conn, conn: conn,
broker: h.broker, broker: h.broker,
outCh: make(chan []byte, 128), user: user,
updateCh: make(chan broker.Update, 256), policy: h.policy,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()), subs: make(map[broker.SignalRef]func()),
log: h.log, log: h.log,
} }
@@ -115,7 +133,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
wg.Add(3) wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }() go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
go func() { defer wg.Done(); c.dispatchLoop(ctx) }() 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() wg.Wait()
// Cancel all subscriptions on disconnect. // Cancel all subscriptions on disconnect.
@@ -134,6 +152,8 @@ type wsClient struct {
conn *websocket.Conn conn *websocket.Conn
broker *broker.Broker broker *broker.Broker
log *slog.Logger 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 outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker 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. // 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 { for {
select { select {
case msg := <-c.outCh: case msg := <-c.outCh:
@@ -251,8 +274,11 @@ func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
c.subs[ref] = cancelSub c.subs[ref] = cancelSub
c.subsMu.Unlock() c.subsMu.Unlock()
// Send metadata once on subscribe. // Send metadata in a goroutine: for EPICS signals GetMetadata may block
c.sendMeta(ctx, ref) // 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) { 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) ds, ok := c.broker.Source(msg.DS)
if !ok { if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name) 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 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() 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 { 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.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "WRITE_ERROR", err.Error()) c.sendError(ctx, "WRITE_ERROR", err.Error())
@@ -341,7 +378,7 @@ func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
b, _ := json.Marshal(resp) b, _ := json.Marshal(resp)
select { select {
case c.outCh <- b: case c.outCh <- b:
default: case <-ctx.Done():
} }
} }
@@ -390,6 +427,8 @@ func metadataToPayload(m datasource.Metadata) *metaPayload {
DriveHigh: m.DriveHigh, DriveHigh: m.DriveHigh,
EnumStrings: m.EnumStrings, EnumStrings: m.EnumStrings,
Writable: m.Writable, Writable: m.Writable,
Properties: m.Properties,
Tags: m.Tags,
} }
} }
+343
View File
@@ -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
View File
@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -22,9 +24,20 @@ type InterfaceMeta struct {
Version int `json:"version"` 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 { 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. // 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 { if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create interfaces dir: %w", err) 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 // 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 var out []InterfaceMeta
for _, e := range entries { 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 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) meta, err := s.readMeta(id)
if err != nil { if err != nil {
continue // skip corrupt files silently continue // skip corrupt files silently
@@ -79,12 +113,27 @@ func (s *Store) List() ([]InterfaceMeta, error) {
return out, nil 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. // rootAttrs is used to parse only the top-level XML attributes.
type rootAttrs struct { type rootAttrs struct {
XMLName xml.Name `xml:"interface"` XMLName xml.Name `xml:"interface"`
ID string `xml:"id,attr"` ID string `xml:"id,attr"`
Name string `xml:"name,attr"` Name string `xml:"name,attr"`
Version int `xml:"version,attr"` Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
} }
func (s *Store) readMeta(id string) (InterfaceMeta, error) { 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. // Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to // The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness. // ensure uniqueness. If tag is non-empty it is stamped as the revision label.
func (s *Store) Create(xmlData []byte) (string, error) { func (s *Store) Create(xmlData []byte, tag string) (string, error) {
var root rootAttrs var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil { if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err) 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 { if _, err := os.Stat(s.filePath(id)); err == nil {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli()) 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)
}
}
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
} }
// Update replaces the XML for an existing interface. // Update replaces the XML for an existing interface, preserving the previous
func (s *Store) Update(id string, xmlData []byte) error { // 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 { if err := validateID(id); err != nil {
return ErrNotFound 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 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 return ErrNotFound
} }
return os.WriteFile(s.filePath(id), xmlData, 0o644) 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. // Delete removes the interface with the given ID.
+232
View File
@@ -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)
}
}
+434
View File
@@ -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
}
}
+397
View File
@@ -0,0 +1,397 @@
package ca_test
import (
"context"
"math"
"testing"
"time"
"github.com/uopi/goca"
"github.com/uopi/goca/testca"
"github.com/uopi/goca/proto"
)
// newTestClient creates a Client pointing only at the fake server's addresses.
func newTestClient(t *testing.T, srv *testca.Server) *ca.Client {
t.Helper()
cfg := ca.Config{
AddrList: []string{srv.UDPAddr()},
AutoAddrList: false,
ClientName: "testclient",
HostName: "localhost",
}
cli, err := ca.NewClient(context.Background(), cfg)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
t.Cleanup(cli.Close)
return cli
}
// newTestServer creates a fake CA server with standard test PVs.
func newTestServer(t *testing.T) *testca.Server {
t.Helper()
srv, err := testca.New([]testca.PVSpec{
{Name: "TEST:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 3.14},
{Name: "TEST:LONG", DBFType: proto.DBFLong, Count: 1, Value: int32(42)},
{Name: "TEST:STRING", DBFType: proto.DBFString, Count: 1, Value: "hello"},
{Name: "TEST:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(1)},
{Name: "TEST:READONLY", DBFType: proto.DBFDouble, Count: 1, Value: 1.0,
Access: proto.AccessRead},
})
if err != nil {
t.Fatalf("testca.New: %v", err)
}
t.Cleanup(srv.Close)
return srv
}
// -------------------------------------------------------------------------- //
// Get tests //
// -------------------------------------------------------------------------- //
func TestGetDouble(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tv, err := cli.Get(ctx, "TEST:DOUBLE")
if err != nil {
t.Fatalf("Get: %v", err)
}
if math.Abs(tv.Double-3.14) > 1e-6 {
t.Errorf("Double = %g, want ~3.14", tv.Double)
}
if tv.Timestamp.IsZero() {
t.Error("Timestamp is zero")
}
}
func TestGetLong(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tv, err := cli.Get(ctx, "TEST:LONG")
if err != nil {
t.Fatalf("Get: %v", err)
}
if tv.Long != 42 {
t.Errorf("Long = %d, want 42", tv.Long)
}
}
func TestGetString(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tv, err := cli.Get(ctx, "TEST:STRING")
if err != nil {
t.Fatalf("Get: %v", err)
}
if tv.Str != "hello" {
t.Errorf("Str = %q, want %q", tv.Str, "hello")
}
}
func TestGetNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_, err := cli.Get(ctx, "NO:SUCH:PV")
if err == nil {
t.Fatal("expected error for missing PV, got nil")
}
}
// -------------------------------------------------------------------------- //
// Subscribe tests //
// -------------------------------------------------------------------------- //
func TestSubscribeReceivesInitialValue(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
select {
case tv := <-ch:
if math.Abs(tv.Double-3.14) > 1e-6 {
t.Errorf("initial Double = %g, want ~3.14", tv.Double)
}
case <-ctx.Done():
t.Fatal("timeout waiting for initial monitor value")
}
}
func TestSubscribeReceivesUpdates(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
// Push a new value from the server side.
if err := srv.SetValue("TEST:DOUBLE", 99.9); err != nil {
t.Fatalf("SetValue: %v", err)
}
select {
case tv := <-ch:
if math.Abs(tv.Double-99.9) > 1e-6 {
t.Errorf("updated Double = %g, want ~99.9", tv.Double)
}
case <-ctx.Done():
t.Fatal("timeout waiting for updated monitor value")
}
}
func TestUnsubscribeStopsUpdates(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
// Drain initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
unsub() // unsubscribe
// Give the EVENT_CANCEL message time to be delivered.
time.Sleep(50 * time.Millisecond)
// Push a value; channel should NOT receive it.
srv.SetValue("TEST:DOUBLE", 777.0)
select {
case tv := <-ch:
// It's possible one update slipped through before cancel was processed.
// Accept it, but not a second one.
t.Logf("received one update after unsub (value=%g), checking for second...", tv.Double)
select {
case <-ch:
t.Error("received second update after unsubscribe")
case <-time.After(100 * time.Millisecond):
// Good — no second update.
}
case <-time.After(150 * time.Millisecond):
// No update received — correct.
}
}
// -------------------------------------------------------------------------- //
// Put tests //
// -------------------------------------------------------------------------- //
func TestPutDouble(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Subscribe to observe the effect of Put.
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
if err := cli.Put(ctx, "TEST:DOUBLE", float64(2.718)); err != nil {
t.Fatalf("Put: %v", err)
}
select {
case tv := <-ch:
if math.Abs(tv.Double-2.718) > 1e-6 {
t.Errorf("post-put Double = %g, want ~2.718", tv.Double)
}
case <-ctx.Done():
t.Fatal("timeout waiting for post-put monitor value")
}
}
func TestPutLong(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:LONG", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
<-ch // drain initial
if err := cli.Put(ctx, "TEST:LONG", int(100)); err != nil {
t.Fatalf("Put: %v", err)
}
select {
case tv := <-ch:
if tv.Long != 100 {
t.Errorf("post-put Long = %d, want 100", tv.Long)
}
case <-ctx.Done():
t.Fatal("timeout waiting for post-put value")
}
}
func TestPutString(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:STRING", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
<-ch // drain initial
if err := cli.Put(ctx, "TEST:STRING", "world"); err != nil {
t.Fatalf("Put: %v", err)
}
select {
case tv := <-ch:
if tv.Str != "world" {
t.Errorf("post-put Str = %q, want %q", tv.Str, "world")
}
case <-ctx.Done():
t.Fatal("timeout waiting for post-put value")
}
}
func TestPutReadOnly(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := cli.Put(ctx, "TEST:READONLY", float64(1.0))
if err == nil {
t.Fatal("expected error writing to read-only PV")
}
}
// -------------------------------------------------------------------------- //
// Multiple subscribers on the same PV //
// -------------------------------------------------------------------------- //
func TestMultipleSubscribers(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
const n = 3
chs := make([]chan proto.TimeValue, n)
for i := range chs {
chs[i] = make(chan proto.TimeValue, 8)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", chs[i])
if err != nil {
t.Fatalf("Subscribe[%d]: %v", i, err)
}
defer unsub()
}
// Drain initial values.
for i, ch := range chs {
select {
case <-ch:
case <-ctx.Done():
t.Fatalf("timeout waiting for initial value on subscriber %d", i)
}
}
// Push a new value.
srv.SetValue("TEST:DOUBLE", 55.5)
for i, ch := range chs {
select {
case tv := <-ch:
if math.Abs(tv.Double-55.5) > 1e-6 {
t.Errorf("subscriber %d: Double = %g, want 55.5", i, tv.Double)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for update on subscriber %d", i)
}
}
}
// -------------------------------------------------------------------------- //
// Context cancellation //
// -------------------------------------------------------------------------- //
func TestGetContextCancelled(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
_, err := cli.Get(ctx, "TEST:DOUBLE")
if err == nil {
t.Fatal("expected error with cancelled context")
}
}
+776
View File
@@ -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()
}
}
+26
View File
@@ -0,0 +1,26 @@
package ca
import (
"log/slog"
"os"
)
// calog is the package-level debug logger.
// It is active only when the environment variable UOPI_CA_DEBUG is set to a
// non-empty value at program startup. All output goes to stderr.
var calog *slog.Logger
func init() {
if os.Getenv("UOPI_CA_DEBUG") != "" {
calog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
}
// dbg emits a DEBUG log if UOPI_CA_DEBUG is set. It is a no-op otherwise.
func dbg(msg string, args ...any) {
if calog != nil {
calog.Debug(msg, args...)
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package ca implements an EPICS Channel Access (CA) client in pure Go.
//
// It requires no CGo, no libca, and no EPICS installation on the build machine.
// A single [Client] manages connections to one or more IOCs, multiplexing
// channels over shared TCP virtual circuits that survive IOC restarts.
//
// # Quick start
//
// // Configure from standard EPICS environment variables.
// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv())
// if err != nil { ... }
// defer cli.Close()
//
// // Subscribe to live monitor updates.
// ch := make(chan proto.TimeValue, 16)
// cancel, err := cli.Subscribe(ctx, "MY:PV", ch)
// if err != nil { ... }
// defer cancel()
// for tv := range ch {
// fmt.Println(tv.Timestamp, tv.Double)
// }
//
// // One-shot read (current value).
// tv, err := cli.Get(ctx, "MY:PV")
//
// // Retrieve full control-block metadata (units, display limits, enum strings).
// ci, err := cli.GetCtrl(ctx, "MY:PV")
// if ci.Double != nil {
// fmt.Println(ci.Double.Units, ci.Double.UpperDispLimit)
// }
//
// // Write a new value (fire-and-forget).
// err = cli.Put(ctx, "MY:PV", 42.0)
//
// # Configuration
//
// [ConfigFromEnv] reads the standard EPICS CA environment variables:
//
// EPICS_CA_ADDR_LIST space-separated list of server addresses
// EPICS_CA_AUTO_ADDR_LIST set to "NO" to disable local broadcast
//
// Alternatively, construct [Config] directly and pass to [NewClient].
//
// # Protocol
//
// The library speaks Channel Access protocol version 4.13 over UDP (search,
// port 5064) and TCP (data, port 5064). It does not use the CA Repeater;
// it sends search requests directly to the addresses in [Config.AddrList].
//
// UDP search uses exponential back-off (100 ms → 30 s) and retries until the
// PV is found or the context is cancelled.
//
// # Reconnection
//
// When an IOC restarts, the library detects the broken TCP connection and
// reconnects automatically. All active subscriptions are re-established
// transparently after the new CREATE_CHAN handshake completes.
// Callers blocked in [Client.Get] or [Client.Put] during a reconnect will
// receive an error and should retry.
//
// # Thread safety
//
// All exported methods are safe for concurrent use from multiple goroutines.
// A single [Client] can be shared across the entire application.
package ca
+3
View File
@@ -0,0 +1,3 @@
module github.com/uopi/goca
go 1.26
+444
View File
@@ -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
}
+108
View File
@@ -0,0 +1,108 @@
package proto
import (
"encoding/binary"
"fmt"
"io"
)
// Header is the decoded form of a 16-byte CA message header.
// All integer fields are stored in host byte order after decoding.
type Header struct {
Command uint16
PayloadSize uint32 // actual payload size (resolved from extended form if needed)
DataType uint16
DataCount uint32 // actual element count (resolved from extended form if needed)
Parameter1 uint32
Parameter2 uint32
}
// HeaderSize is the wire size of a standard CA header.
const HeaderSize = 16
// Encode writes the header to buf (must be at least HeaderSize bytes).
// If PayloadSize > 0xFFFE or DataCount > 0xFFFF the extended encoding is used
// and the caller must prepend an extra 8 bytes; use EncodeExtended instead.
func (h Header) Encode(buf []byte) {
binary.BigEndian.PutUint16(buf[0:], h.Command)
binary.BigEndian.PutUint16(buf[2:], uint16(h.PayloadSize))
binary.BigEndian.PutUint16(buf[4:], h.DataType)
binary.BigEndian.PutUint16(buf[6:], uint16(h.DataCount))
binary.BigEndian.PutUint32(buf[8:], h.Parameter1)
binary.BigEndian.PutUint32(buf[12:], h.Parameter2)
}
// Bytes returns the 16-byte wire encoding of h.
func (h Header) Bytes() []byte {
buf := make([]byte, HeaderSize)
h.Encode(buf)
return buf
}
// DecodeHeader reads exactly one CA header from r, handling the extended
// encoding (payload_size == 0xFFFF) transparently.
// Returns the decoded Header and the number of bytes read (16 or 24).
func DecodeHeader(r io.Reader) (Header, int, error) {
var raw [HeaderSize]byte
if _, err := io.ReadFull(r, raw[:]); err != nil {
return Header{}, 0, fmt.Errorf("ca: read header: %w", err)
}
h := Header{
Command: binary.BigEndian.Uint16(raw[0:]),
DataType: binary.BigEndian.Uint16(raw[4:]),
Parameter1: binary.BigEndian.Uint32(raw[8:]),
Parameter2: binary.BigEndian.Uint32(raw[12:]),
}
rawPayload := binary.BigEndian.Uint16(raw[2:])
rawCount := binary.BigEndian.Uint16(raw[6:])
// Extended message: payload_size == 0xFFFF means real sizes are in the
// next 8 bytes (parameter1 = real payload size, parameter2 = real count).
if rawPayload == 0xFFFF && rawCount == 0x0000 {
var ext [8]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return Header{}, 16, fmt.Errorf("ca: read extended header: %w", err)
}
h.PayloadSize = binary.BigEndian.Uint32(ext[0:])
h.DataCount = binary.BigEndian.Uint32(ext[4:])
// Note: parameter1/2 in the base header are overwritten by the extended values.
// In practice, the original parameter1/2 are still valid — the extended header
// only carries sizes, not the original parameters. We already decoded parameter1/2
// above from the base header.
return h, 24, nil
}
h.PayloadSize = uint32(rawPayload)
h.DataCount = uint32(rawCount)
return h, 16, nil
}
// PadTo8 returns n rounded up to the nearest multiple of 8.
// CA requires all message payloads to be padded to 8-byte boundaries.
func PadTo8(n int) int {
return (n + 7) &^ 7
}
// PadBytes appends zero bytes to b until len(b) is a multiple of 8.
func PadBytes(b []byte) []byte {
need := PadTo8(len(b)) - len(b)
return append(b, make([]byte, need)...)
}
// BuildMessage assembles a complete CA message (header + payload).
// payload may be nil for zero-length messages.
func BuildMessage(h Header, payload []byte) []byte {
h.PayloadSize = uint32(len(payload))
msg := make([]byte, HeaderSize+len(payload))
h.Encode(msg)
copy(msg[HeaderSize:], payload)
return msg
}
// BuildStringPayload encodes a string as a null-terminated, 8-byte padded payload.
func BuildStringPayload(s string) []byte {
b := append([]byte(s), 0) // null terminator
return PadBytes(b)
}
+140
View File
@@ -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
}
}
+387
View File
@@ -0,0 +1,387 @@
package proto_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
)
// -------------------------------------------------------------------------- //
// Header round-trip //
// -------------------------------------------------------------------------- //
func TestHeaderRoundTrip(t *testing.T) {
h := proto.Header{
Command: proto.CmdCreateChan,
PayloadSize: 16,
DataType: proto.DBFDouble,
DataCount: 1,
Parameter1: 0xDEAD,
Parameter2: 0xBEEF,
}
wire := h.Bytes()
if len(wire) != proto.HeaderSize {
t.Fatalf("Bytes() len = %d, want %d", len(wire), proto.HeaderSize)
}
got, n, err := proto.DecodeHeader(bytes.NewReader(wire))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if n != proto.HeaderSize {
t.Errorf("bytes consumed = %d, want %d", n, proto.HeaderSize)
}
if got.Command != h.Command {
t.Errorf("Command = %d, want %d", got.Command, h.Command)
}
if got.DataType != h.DataType {
t.Errorf("DataType = %d, want %d", got.DataType, h.DataType)
}
if got.Parameter1 != h.Parameter1 {
t.Errorf("Parameter1 = %d, want %d", got.Parameter1, h.Parameter1)
}
if got.Parameter2 != h.Parameter2 {
t.Errorf("Parameter2 = %d, want %d", got.Parameter2, h.Parameter2)
}
}
func TestBuildMessage(t *testing.T) {
payload := []byte("hello\x00\x00\x00") // 8 bytes (padded)
h := proto.Header{Command: proto.CmdHostName, PayloadSize: 0}
msg := proto.BuildMessage(h, payload)
if len(msg) != proto.HeaderSize+len(payload) {
t.Fatalf("len(msg) = %d, want %d", len(msg), proto.HeaderSize+len(payload))
}
// PayloadSize in wire should equal len(payload).
wireSize := binary.BigEndian.Uint16(msg[2:4])
if int(wireSize) != len(payload) {
t.Errorf("wire PayloadSize = %d, want %d", wireSize, len(payload))
}
}
func TestPadTo8(t *testing.T) {
cases := [][2]int{{0, 0}, {1, 8}, {7, 8}, {8, 8}, {9, 16}, {16, 16}, {17, 24}}
for _, c := range cases {
if got := proto.PadTo8(c[0]); got != c[1] {
t.Errorf("PadTo8(%d) = %d, want %d", c[0], got, c[1])
}
}
}
func TestBuildStringPayload(t *testing.T) {
p := proto.BuildStringPayload("TEST")
if len(p)%8 != 0 {
t.Errorf("payload length %d not padded to 8", len(p))
}
if p[4] != 0 {
t.Errorf("expected null terminator at index 4, got %d", p[4])
}
}
// -------------------------------------------------------------------------- //
// DBR_TIME_* decoding //
// -------------------------------------------------------------------------- //
// buildTimeHeader builds the 12-byte common DBR_TIME header.
func buildTimeHeader(status, severity int16, sec, nsec uint32) []byte {
b := make([]byte, 12)
binary.BigEndian.PutUint16(b[0:], uint16(status))
binary.BigEndian.PutUint16(b[2:], uint16(severity))
binary.BigEndian.PutUint32(b[4:], sec)
binary.BigEndian.PutUint32(b[8:], nsec)
return b
}
func TestDecodeTimeDouble(t *testing.T) {
const sec = 1_000_000
const nsec = 500_000_000
const want = 3.14159
hdr := buildTimeHeader(0, 0, sec, nsec)
pad := make([]byte, 4) // RISC pad
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, math.Float64bits(want))
payload := append(append(hdr, pad...), val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if math.Abs(tv.Double-want) > 1e-10 {
t.Errorf("Double = %g, want %g", tv.Double, want)
}
// EPICS epoch offset: 1990-01-01 00:00:00 UTC = Unix 631152000.
wantUnix := int64(sec) + 631152000
if tv.Timestamp.Unix() != wantUnix {
t.Errorf("Timestamp.Unix() = %d, want %d", tv.Timestamp.Unix(), wantUnix)
}
if tv.Timestamp.Nanosecond() != int(nsec) {
t.Errorf("Timestamp.Nanosecond() = %d, want %d", tv.Timestamp.Nanosecond(), nsec)
}
}
func TestDecodeTimeLong(t *testing.T) {
hdr := buildTimeHeader(0, 1, 0, 0)
val := make([]byte, 4)
binary.BigEndian.PutUint32(val, 0xFFFFFFD6) // -42 as two's complement
payload := append(hdr, val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeLong, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if tv.Long != -42 {
t.Errorf("Long = %d, want -42", tv.Long)
}
if tv.Severity != proto.SeverityMinor {
t.Errorf("Severity = %v, want Minor", tv.Severity)
}
}
func TestDecodeTimeString(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
str := make([]byte, 40)
copy(str, "hello")
payload := append(hdr, str...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeString, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if tv.Str != "hello" {
t.Errorf("Str = %q, want %q", tv.Str, "hello")
}
}
func TestDecodeTimeEnum(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 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)
}
}
+395
View File
@@ -0,0 +1,395 @@
package ca
import (
"context"
"encoding/binary"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"github.com/uopi/goca/proto"
)
// searchResult is returned to a waiter when a search succeeds.
type searchResult struct {
addr string // "ip:port" TCP address of the IOC
}
// searchWaiter tracks an outstanding PV search request.
type searchWaiter struct {
pvName string
ch chan searchResult // closed or sent to exactly once
}
// searchEngine performs UDP-based PV name → IOC address resolution.
// It sends CA_PROTO_SEARCH datagrams to all configured server addresses and
// listens for replies, matching them to pending waiters via a searchID.
type searchEngine struct {
addrs []string // "ip:port" strings to search
mu sync.Mutex
waiters map[uint32]*searchWaiter // searchID → waiter
idSeq atomic.Uint32
conn *net.UDPConn // shared UDP socket (nil if not started)
}
func newSearchEngine(addrs []string) *searchEngine {
return &searchEngine{
addrs: addrs,
waiters: make(map[uint32]*searchWaiter),
}
}
// start opens the shared UDP socket and launches the receive goroutine.
// ctx cancellation stops the receive loop and closes the socket.
func (se *searchEngine) start(ctx context.Context) error {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
return fmt.Errorf("ca search: open UDP socket: %w", err)
}
se.conn = conn
go se.recvLoop(ctx)
return nil
}
// lookup resolves pvName to an IOC TCP address.
// It retries with exponential back-off until ctx is cancelled or done.
func (se *searchEngine) lookup(ctx context.Context, pvName string) (string, error) {
id := se.idSeq.Add(1)
w := &searchWaiter{
pvName: pvName,
ch: make(chan searchResult, 1),
}
se.mu.Lock()
se.waiters[id] = w
se.mu.Unlock()
defer func() {
se.mu.Lock()
delete(se.waiters, id)
se.mu.Unlock()
}()
delay := 100 * time.Millisecond
const maxDelay = 30 * time.Second
for {
if err := se.sendSearch(id, pvName); err != nil {
return "", err
}
select {
case res := <-w.ch:
return res.addr, nil
case <-time.After(delay):
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
case <-ctx.Done():
return "", fmt.Errorf("ca search %q: %w", pvName, ctx.Err())
}
}
}
// sendSearch sends a CA_PROTO_VERSION + CA_PROTO_SEARCH burst to all addresses.
// The CA protocol requires both messages to be in the same UDP datagram.
func (se *searchEngine) sendSearch(id uint32, pvName string) error {
payload := proto.BuildStringPayload(pvName)
// VERSION message (no payload).
verHdr := proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}
// SEARCH message.
searchHdr := proto.Header{
Command: proto.CmdSearch,
DataType: proto.SearchReply,
DataCount: proto.MinorVersion,
Parameter1: id,
Parameter2: id,
}
searchMsg := proto.BuildMessage(searchHdr, payload)
// Bundle into a single datagram — softIoc (and the CA Repeater) require
// VERSION and SEARCH to arrive together in one UDP packet.
pkt := make([]byte, 0, proto.HeaderSize+len(searchMsg))
pkt = append(pkt, verHdr.Bytes()...)
pkt = append(pkt, searchMsg...)
for _, addr := range se.addrs {
udpAddr, err := net.ResolveUDPAddr("udp4", addr)
if err != nil {
continue
}
_, _ = se.conn.WriteTo(pkt, udpAddr)
dbg("CA search sent", "pv", pvName, "id", id, "dst", udpAddr)
}
return nil
}
// recvLoop reads UDP datagrams and dispatches search replies.
func (se *searchEngine) recvLoop(ctx context.Context) {
buf := make([]byte, 65536)
for {
// Use a short read deadline so we can check ctx.Done() periodically.
_ = se.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, srcAddr, err := se.conn.ReadFromUDP(buf)
if err != nil {
if ctx.Err() != nil {
return
}
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
continue
}
dbg("CA UDP datagram received", "src", srcAddr, "bytes", n)
se.parseReply(buf[:n], srcAddr)
}
}
// parseReply scans a UDP datagram for CA_PROTO_SEARCH response messages and
// wakes the matching waiter.
func (se *searchEngine) parseReply(data []byte, src *net.UDPAddr) {
for len(data) >= proto.HeaderSize {
h, n, err := proto.DecodeHeader(newBytesReader(data))
if err != nil {
return
}
payloadEnd := n + int(h.PayloadSize)
if payloadEnd > len(data) {
return
}
payload := data[n:payloadEnd]
data = data[payloadEnd:]
if h.Command != proto.CmdSearch {
continue
}
// data_type field = TCP port of the CA server.
tcpPort := int(h.DataType)
searchID := h.Parameter2
// Resolve server IP.
//
// Two payload formats exist in the wild:
//
// New (≥ CA 4.9): [IP(4)][pad(4)]
// Old (< CA 4.9): [minor_version(2)][pad(2)][IP(4)]
//
// The old format is identified by payload[0] == 0x00 (the high byte of a
// uint16 minor version is always zero for CA versions ≤ 255, whereas a
// real IPv4 address never starts with 0x00).
//
// In both formats, 0xFFFFFFFF in the IP position means "use sender IP".
var serverIP net.IP
if len(payload) >= 8 && payload[0] == 0x00 {
// Old format: minor version at [0:2], IP at [4:8].
ipSlice := payload[4:8]
if useSenderIP(ipSlice) {
serverIP = src.IP
} else {
ip := make(net.IP, 4)
copy(ip, ipSlice)
serverIP = ip
}
} else if len(payload) >= 4 {
// New format: IP at [0:4].
if useSenderIP(payload[:4]) {
serverIP = src.IP
} else {
ip := make(net.IP, 4)
copy(ip, payload[:4])
serverIP = ip
}
} else {
serverIP = src.IP
}
tcpAddr := fmt.Sprintf("%s:%d", serverIP.String(), tcpPort)
dbg("CA search reply", "searchID", searchID, "tcpAddr", tcpAddr, "src", src)
se.mu.Lock()
w, ok := se.waiters[searchID]
if ok {
delete(se.waiters, searchID)
}
se.mu.Unlock()
if ok {
dbg("CA search matched", "pv", w.pvName, "ioc", tcpAddr)
select {
case w.ch <- searchResult{addr: tcpAddr}:
default:
}
} else {
dbg("CA search reply unmatched", "searchID", searchID, "known_waiters", func() int {
se.mu.Lock()
defer se.mu.Unlock()
return len(se.waiters)
}())
}
}
}
// useSenderIP reports whether an IP field in a CA search reply means
// "use the sender's address". Both 0.0.0.0 (all-zeros) and 255.255.255.255
// (all-ones / 0xFFFFFFFF) are used by different EPICS Base versions.
func useSenderIP(b []byte) bool {
allFF := true
allZero := true
for _, v := range b {
if v != 0xFF {
allFF = false
}
if v != 0x00 {
allZero = false
}
}
return allFF || allZero
}
func isAllFF(b []byte) bool {
for _, v := range b {
if v != 0xFF {
return false
}
}
return true
}
// -------------------------------------------------------------------------- //
// Address list helpers //
// -------------------------------------------------------------------------- //
// resolveAddrs converts a list of "host" or "host:port" strings to
// "host:port" strings using defaultPort when no port is specified.
func resolveAddrs(addrs []string, defaultPort int) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
if a == "" {
continue
}
_, _, err := net.SplitHostPort(a)
if err != nil {
// No port specified; append default.
a = fmt.Sprintf("%s:%d", a, defaultPort)
}
out = append(out, a)
}
return out
}
// localBroadcastAddrs returns broadcast addresses for all local network
// interfaces, used when AutoAddrList is true.
func localBroadcastAddrs(port int) []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var out []string
for _, iface := range ifaces {
if iface.Flags&net.FlagBroadcast == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil {
continue
}
// Compute broadcast: IP | ^mask
mask := ipNet.Mask
bcast := make(net.IP, 4)
for i := range bcast {
bcast[i] = ip4[i] | ^mask[i]
}
out = append(out, fmt.Sprintf("%s:%d", bcast.String(), port))
}
}
return out
}
// -------------------------------------------------------------------------- //
// Minimal bytes.Reader-like for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct {
b []byte
i int
}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
func (r *bytesReader) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, fmt.Errorf("EOF")
}
n := copy(p, r.b[r.i:])
r.i += n
return n, nil
}
// -------------------------------------------------------------------------- //
// searchAddrFromReply extracts server IP from a CA_PROTO_SEARCH response. //
// Exported for testability. //
// -------------------------------------------------------------------------- //
// EncodedSearchPair builds the VERSION + SEARCH UDP datagram pair for pvName
// with the given searchID. Exported for use in tests.
func EncodedSearchPair(pvName string, searchID uint32) []byte {
verHdr := proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}
payload := proto.BuildStringPayload(pvName)
searchHdr := proto.Header{
Command: proto.CmdSearch,
DataType: proto.SearchReply,
DataCount: proto.MinorVersion,
Parameter1: searchID,
Parameter2: searchID,
}
searchMsg := proto.BuildMessage(searchHdr, payload)
out := make([]byte, 0, proto.HeaderSize+len(searchMsg))
out = append(out, verHdr.Bytes()...)
out = append(out, searchMsg...)
return out
}
// EncodeSearchReply builds a CA_PROTO_SEARCH UDP reply.
// serverIP may be nil to use 0xFFFFFFFF (sender IP). port is the CA TCP port.
func EncodeSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
var payload []byte
if serverIP != nil {
payload = make([]byte, 8)
copy(payload[:4], serverIP.To4())
binary.BigEndian.PutUint32(payload[4:], 0)
} else {
payload = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
}
h := proto.Header{
Command: proto.CmdSearch,
DataType: uint16(port),
Parameter1: proto.MinorVersion,
Parameter2: searchID,
}
return proto.BuildMessage(h, payload)
}
+137
View File
@@ -0,0 +1,137 @@
package ca
import (
"net"
"testing"
"github.com/uopi/goca/proto"
)
// buildOldSearchReply builds a CA_PROTO_SEARCH UDP reply using the old
// payload format (CA < 4.9): [minor_version(2)][pad(2)][IP(4)].
// When ip is nil, the IP field is 0xFFFFFFFF (use-sender convention).
func buildOldSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
payload := make([]byte, 8)
payload[0] = 0x00
payload[1] = proto.MinorVersion // e.g. 0x0D = 13
// payload[2:4] = 0x00 0x00 (pad)
if serverIP == nil {
payload[4] = 0xFF
payload[5] = 0xFF
payload[6] = 0xFF
payload[7] = 0xFF
} else {
copy(payload[4:], serverIP.To4())
}
h := proto.Header{
Command: proto.CmdSearch,
DataType: uint16(port),
Parameter1: proto.MinorVersion,
Parameter2: searchID,
}
return proto.BuildMessage(h, payload)
}
// TestParseReplyOldFormatAllFF verifies the old format with 0xFFFFFFFF IP.
func TestParseReplyOldFormatAllFF(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
const searchID = uint32(42)
result := make(chan searchResult, 1)
se.mu.Lock()
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result}
se.mu.Unlock()
pkt := buildOldSearchReply(searchID, nil, 5064) // nil → 0xFFFFFFFF
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
se.parseReply(pkt, src)
select {
case r := <-result:
if r.addr != "127.0.0.1:5064" {
t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr)
}
default:
t.Fatal("parseReply did not deliver a result")
}
}
// TestParseReplyOldFormatAllZero verifies the old format with 0.0.0.0 IP
// (used by some EPICS Base versions to also mean "use sender IP").
func TestParseReplyOldFormatAllZero(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
const searchID = uint32(43)
result := make(chan searchResult, 1)
se.mu.Lock()
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result}
se.mu.Unlock()
pkt := buildOldSearchReply(searchID, net.IPv4(0, 0, 0, 0).To4(), 5064)
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
se.parseReply(pkt, src)
select {
case r := <-result:
if r.addr != "127.0.0.1:5064" {
t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr)
}
default:
t.Fatal("parseReply did not deliver a result")
}
}
// TestParseReplyOldFormatExplicitIP verifies that an explicit server IP in
// the old payload format is read from the correct offset (bytes [4:8]).
func TestParseReplyOldFormatExplicitIP(t *testing.T) {
se := &searchEngine{
waiters: make(map[uint32]*searchWaiter),
}
const searchID = uint32(7)
result := make(chan searchResult, 1)
se.mu.Lock()
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV2", ch: result}
se.mu.Unlock()
explicitIP := net.ParseIP("10.0.0.5").To4()
pkt := buildOldSearchReply(searchID, explicitIP, 5064)
src := &net.UDPAddr{IP: net.ParseIP("10.0.0.5"), Port: 5064}
se.parseReply(pkt, src)
select {
case r := <-result:
if r.addr != "10.0.0.5:5064" {
t.Errorf("addr = %q, want 10.0.0.5:5064", r.addr)
}
default:
t.Fatal("parseReply did not deliver a result")
}
}
// TestParseReplyNewFormat verifies the new payload format (IP at [0:4]).
func TestParseReplyNewFormat(t *testing.T) {
se := &searchEngine{
waiters: make(map[uint32]*searchWaiter),
}
const searchID = uint32(99)
result := make(chan searchResult, 1)
se.mu.Lock()
se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV3", ch: result}
se.mu.Unlock()
// New format: IP = 0xFFFFFFFF at [0:4].
pkt := EncodeSearchReply(searchID, nil, 5064)
src := &net.UDPAddr{IP: net.ParseIP("192.168.1.10"), Port: 5064}
se.parseReply(pkt, src)
select {
case r := <-result:
if r.addr != "192.168.1.10:5064" {
t.Errorf("addr = %q, want 192.168.1.10:5064", r.addr)
}
default:
t.Fatal("parseReply did not deliver a result")
}
}
+605
View File
@@ -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
}
+340
View File
@@ -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
View File
@@ -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())
}
+21
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/uopi/gopva
go 1.22
+232
View File
@@ -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 47 : 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
}
+73
View File
@@ -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
}
+679
View File
@@ -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.
// 0x000xFE → 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: 0x280x2F, 0x4A0x4B, 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
}
}
+220
View File
@@ -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)
}
}
}
+2
View File
@@ -80,6 +80,7 @@ func main() {
// Copy uPlot CSS and favicon // Copy uPlot CSS and favicon
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css")) 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.svg"))
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.ico"))
// Write index.html // Write index.html
html := `<!doctype html> html := `<!doctype html>
@@ -88,6 +89,7 @@ func main() {
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uopi</title> <title>uopi</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/uplot.css" /> <link rel="stylesheet" href="/uplot.css" />
<link rel="stylesheet" href="/main.css" /> <link rel="stylesheet" href="/main.css" />
+33 -5
View File
@@ -2,11 +2,39 @@
listen = ":8080" listen = ":8080"
storage_dir = "./interfaces" 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] [datasource.epics]
enabled = true enabled = true
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set 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] [datasource.synthetic]
enabled = true enabled = true
definitions_file = "./synthetic.json"
+39 -11
View File
@@ -4,7 +4,9 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode'; import ViewMode from './ViewMode';
import EditMode from './EditMode'; import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode'; 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'; type AppMode = 'view' | 'edit';
@@ -15,6 +17,10 @@ export default function App() {
const [mode, setMode] = useState<AppMode>('view'); const [mode, setMode] = useState<AppMode>('view');
const [wsStatus, setWsStatus] = useState('connecting'); const [wsStatus, setWsStatus] = useState('connecting');
const [editTarget, setEditTarget] = useState<Interface | null>(null); 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(() => { useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus); const unsub = wsClient.status.subscribe(setWsStatus);
@@ -24,7 +30,9 @@ export default function App() {
useEffect(() => { useEffect(() => {
const dpr = window.devicePixelRatio ?? 1; const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr)); document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
if (!fsParam) wsClient.connect('/ws'); if (!fsParam) wsClient.connect('/ws');
fetchMe().then(setMe);
}, []); }, []);
if (fsParam) { if (fsParam) {
@@ -36,20 +44,40 @@ export default function App() {
setMode('edit'); 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'); setMode('view');
setEditTarget(null); setEditTarget(null);
} }
if (!canRead(me.level)) {
return (
<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 ( return (
<div> <AuthContext.Provider value={me}>
{wsStatus === 'disconnected' && ( <div class="app-root">
<div class="connection-banner">WebSocket disconnected reconnecting</div> {wsStatus === 'disconnected' && (
)} <div class="connection-banner">WebSocket disconnected reconnecting</div>
{mode === 'view' )}
? <ViewMode onEdit={enterEdit} /> {mode === 'view' ? (
: <EditMode initial={editTarget} onDone={exitEdit} /> <ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
} ) : (
</div> <EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
)}
</div>
</AuthContext.Provider>
); );
} }
+110 -6
View File
@@ -1,5 +1,8 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks'; 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 type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView'; import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel'; import TextLabel from './widgets/TextLabel';
@@ -14,6 +17,9 @@ import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget'; import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget'; import LinkWidget from './widgets/LinkWidget';
import ContextMenu from './ContextMenu'; import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout';
import LogicDialogs from './LogicDialogs';
const COMPONENTS: Record<string, any> = { const COMPONENTS: Record<string, any> = {
textview: TextView, textview: TextView,
@@ -37,6 +43,32 @@ interface CtxState {
signal: SignalRef | null; 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 { interface Props {
iface: Interface | null; iface: Interface | null;
onNavigate?: (interfaceId: string) => void; onNavigate?: (interfaceId: string) => void;
@@ -47,6 +79,18 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
const [ctxMenu, setCtxMenu] = useState<CtxState>({ const [ctxMenu, setCtxMenu] = useState<CtxState>({
visible: false, x: 0, y: 0, signal: null, 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) { function onCtxMenu(e: MouseEvent, widget: Widget) {
e.preventDefault(); 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 }); 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) { if (!iface) {
return ( return (
<div class="canvas-container"> <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 ( return (
<div class="canvas-container"> <div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <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]; const Comp = COMPONENTS[widget.type];
return Comp const inner = Comp
? <Comp ? <Comp
key={widget.id}
widget={widget} widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)} onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate} onNavigate={onNavigate}
@@ -79,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
/> />
: ( : (
<div <div
key={widget.id}
class="unknown-widget" class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`} style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
title={`Unknown widget type: ${widget.type}`} 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> <span class="unknown-label">{widget.type}</span>
</div> </div>
); );
return (
<WidgetView
key={widget.id}
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
>
{inner}
</WidgetView>
);
})} })}
</div> </div>
<ContextMenu <ContextMenu
{...ctxMenu} {...ctxMenu}
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))} onClose={closeCtxMenu}
onInfo={handleInfo}
/> />
{infoSignal && (
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
)}
<LogicDialogs />
</div> </div>
); );
} }
+267
View File
@@ -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
View File
@@ -10,9 +10,10 @@ interface Props {
y: number; y: number;
signal: SignalRef | null; signal: SignalRef | null;
onClose: () => void; 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 [meta, setMeta] = useState<SignalMeta | null>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -36,12 +37,15 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
if (!visible) return null; if (!visible) return null;
function copySignalName() { function copySignalName() {
if (signal) { if (signal) navigator.clipboard.writeText(signal.name).catch(() => {});
navigator.clipboard.writeText(signal.name).catch(() => {});
}
onClose(); onClose();
} }
function openInfo() {
onClose();
onInfo?.();
}
function exportCSV() { function exportCSV() {
// Phase 5+: export CSV data // Phase 5+: export CSV data
onClose(); onClose();
@@ -62,6 +66,8 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
)} )}
</div> </div>
<div class="ctx-divider" /> <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={copySignalName}>Copy signal name</button>
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button> <button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
</div> </div>
+4
View File
@@ -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: '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: '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: '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' }, { text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
], ],
edit: [ 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+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: '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: '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' }, { text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
], ],
}; };
+826
View File
@@ -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 '';
}
}
+3 -2
View File
@@ -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;`}> <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]; const Comp = COMPONENTS[widget.type];
return Comp return Comp
? <Comp key={widget.id} widget={widget} /> ? <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); const isSelected = selectedIds.includes(widget.id);
return ( return (
<div <div
key={`ov-${widget.id}`} key={`ov-${widget.id}`}
+508 -104
View File
@@ -1,16 +1,29 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useCallback, useRef } from 'preact/hooks'; import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget } from './lib/types'; import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml'; import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree'; import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas'; import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane'; import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
interface Props { interface Props {
initial: Interface | null; 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 { function blankInterface(): Interface {
@@ -68,12 +81,15 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W
const STATIC_WIDGET_TYPES = [ const STATIC_WIDGET_TYPES = [
{ type: 'textlabel', label: 'Text Label' }, { type: 'textlabel', label: 'Text Label' },
{ type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' }, { type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' }, { type: 'link', label: 'Link' },
]; ];
export default function EditMode({ initial, onDone }: Props) { export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface()); const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const me = useAuth();
const writable = canWrite(me.level);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -83,73 +99,223 @@ export default function EditMode({ initial, onDone }: Props) {
const [showInsertMenu, setShowInsertMenu] = useState(false); const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit'); 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 // History panel
const undoStack = useRef<Widget[][]>([]); const [showHistory, setShowHistory] = useState(false);
const redoStack = useRef<Widget[][]>([]); 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 [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
const widgetsRef = useRef(iface.widgets); const widgetsRef = useRef(iface.widgets || []);
widgetsRef.current = iface.widgets; widgetsRef.current = iface.widgets || [];
const layoutRef = useRef(iface.layout);
layoutRef.current = iface.layout;
function pushUndo() { const snapshot = useCallback((): Snapshot => ({
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]]; widgets: [...widgetsRef.current],
layout: layoutRef.current,
}), []);
const pushUndo = useCallback(() => {
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
redoStack.current = []; redoStack.current = [];
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
} }, [snapshot]);
function undo() { const undo = useCallback(() => {
if (undoStack.current.length === 0) return; if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1]; 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); undoStack.current = undoStack.current.slice(0, -1);
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: prev })); setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
setDirty(true); setDirty(true);
} }, [snapshot]);
function redo() { const redo = useCallback(() => {
if (redoStack.current.length === 0) return; if (redoStack.current.length === 0) return;
const next = redoStack.current[0]; const next = redoStack.current[0];
undoStack.current = [...undoStack.current, widgetsRef.current]; undoStack.current = [...undoStack.current, snapshot()];
redoStack.current = redoStack.current.slice(1); redoStack.current = redoStack.current.slice(1);
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: next })); setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
setDirty(true); setDirty(true);
} }, [snapshot]);
const handleWidgetsChange = useCallback((widgets: Widget[]) => { const handleWidgetsChange = useCallback((widgets: Widget[]) => {
pushUndo(); pushUndo();
setIface(f => ({ ...f, widgets })); setIface(f => ({ ...f, widgets }));
setDirty(true); 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) => { const handleWidgetChange = useCallback((updated: Widget) => {
pushUndo(); 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); setDirty(true);
}, []); }, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => { const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated); setIface(updated);
setDirty(true); 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 ───────────────────────────────────────────────────── // ── Align / distribute ─────────────────────────────────────────────────────
function doAlign(mode: string) { const doAlign = useCallback((mode: string) => {
const aligned = alignWidgets(iface.widgets, selectedIds, mode); const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
handleWidgetsChange(aligned); handleWidgetsChange(aligned);
} }, [iface.widgets, selectedIds, handleWidgetsChange]);
function doDistribute(axis: 'h' | 'v') { const doDistribute = useCallback((axis: 'h' | 'v') => {
const distributed = distributeWidgets(iface.widgets, selectedIds, axis); const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
handleWidgetsChange(distributed); handleWidgetsChange(distributed);
} }, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ─────────────────────────────────────────────────── // ── Insert static widget ───────────────────────────────────────────────────
function insertWidget(type: string) { const insertWidget = useCallback((type: string) => {
setShowInsertMenu(false); setShowInsertMenu(false);
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60]; const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
const newWidget: Widget = { const newWidget: Widget = {
@@ -160,22 +326,26 @@ export default function EditMode({ initial, onDone }: Props) {
w: defW, w: defW,
h: defH, h: defH,
signals: [], 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]); setSelectedIds([newWidget.id]);
} }, [iface.widgets, handleWidgetsChange]);
// ── Save / export / import ───────────────────────────────────────────────── // ── Save / export / import ─────────────────────────────────────────────────
async function handleSave() { const handleSave = useCallback(async (saveTag = '') => {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
const xml = serializeInterface(iface); const xml = serializeInterface(iface);
const url = iface.id const base = iface.id
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}` ? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
: '/api/v1/interfaces'; : '/api/v1/interfaces';
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
const method = iface.id ? 'PUT' : 'POST'; const method = iface.id ? 'PUT' : 'POST';
const res = await fetch(url, { const res = await fetch(url, {
method, method,
@@ -188,14 +358,141 @@ export default function EditMode({ initial, onDone }: Props) {
setIface(f => ({ ...f, id: json.id })); setIface(f => ({ ...f, id: json.id }));
} }
setDirty(false); setDirty(false);
setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setSaving(false); 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 xml = serializeInterface(iface);
const blob = new Blob([xml], { type: 'application/xml' }); const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob); 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.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }, [iface]);
function handleImport() { const handleImport = useCallback(() => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.xml,application/xml,text/xml'; input.accept = '.xml,application/xml,text/xml';
@@ -225,48 +522,30 @@ export default function EditMode({ initial, onDone }: Props) {
} }
}; };
input.click(); input.click();
} }, []);
function handleLeave() { const handleLeave = useCallback(async () => {
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return; 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; }
// ── Keyboard shortcuts ───────────────────────────────────────────────────── // Existing panel closed with unsaved edits: reopen the last saved (active)
// version rather than carrying the discarded in-memory changes into view.
function handleKeyDown(e: KeyboardEvent) { if (dirty) {
const target = e.target as Element; try {
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
if (e.key === '?') { openHelp('edit'); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`);
onDone(parseInterface(await res.text()));
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) { } catch {
pushUndo(); onDone(null);
setIface(f => ({ ...f, widgets: f.widgets.filter(w => !selectedIds.includes(w.id)) })); }
setSelectedIds([]);
setDirty(true);
return; return;
} }
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } onDone(iface);
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } }, [dirty, onDone, iface]);
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
));
}
}
const selectedWidget = selectedIds.length === 1 const selectedWidget = selectedIds.length === 1
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null ? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
: null; : null;
const canUndo = historyLen > 0; const canUndo = historyLen > 0;
@@ -274,7 +553,7 @@ export default function EditMode({ initial, onDone }: Props) {
const multiSelected = selectedIds.length > 1; const multiSelected = selectedIds.length > 1;
return ( return (
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}> <div class="edit-mode-layout" tabIndex={-1}>
{/* ── Toolbar ── */} {/* ── Toolbar ── */}
<header class="toolbar"> <header class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
@@ -309,17 +588,19 @@ export default function EditMode({ initial, onDone }: Props) {
<span>Grid</span> <span>Grid</span>
</label> </label>
{/* Insert static widget */} {/* Insert static widget — not applicable to split plot panels */}
<div class="toolbar-dropdown"> {iface.kind !== 'plot' && (
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert </button> <div class="toolbar-dropdown">
{showInsertMenu && ( <button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert </button>
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}> {showInsertMenu && (
{STATIC_WIDGET_TYPES.map(({ type, label }) => ( <div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button> {STATIC_WIDGET_TYPES.map(({ type, label }) => (
))} <button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
</div> ))}
)} </div>
</div> )}
</div>
)}
{/* Align/distribute — only when ≥2 selected */} {/* Align/distribute — only when ≥2 selected */}
{selectedIds.length >= 2 && ( {selectedIds.length >= 2 && (
@@ -344,11 +625,25 @@ export default function EditMode({ initial, onDone }: Props) {
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} /> <ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button> <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={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</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'} {saving ? 'Saving…' : 'Save'}
</button> </button>
<button class="toolbar-btn" onClick={handleLeave}> Close</button> <button class="toolbar-btn" onClick={handleLeave}> Close</button>
@@ -357,21 +652,130 @@ export default function EditMode({ initial, onDone }: Props) {
{/* ── Three-panel body ── */} {/* ── Three-panel body ── */}
<div class="edit-body"> <div class="edit-body">
<SignalTree /> {/* The logic tab is a self-contained flow editor with its own palette and
<EditCanvas inspector, so the layout-only side panes are hidden while it is open. */}
iface={iface} {centerTab !== 'logic' && (
selectedIds={selectedIds} <Fragment>
onSelect={setSelectedIds} <SignalTree
onChange={handleWidgetsChange} width={leftW}
snapGrid={snapGrid} panelId={iface.id}
/> statevars={iface.statevars}
<PropertiesPane onStateVarsChange={handleStateVarsChange}
selected={selectedWidget} />
multiCount={multiSelected ? selectedIds.length : 0} <div class="panel-resize-handle" onMouseDown={startResize('left')} />
iface={iface} </Fragment>
onChange={handleWidgetChange} )}
onIfaceChange={handleIfaceChange} <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> </div>
{showHelp && ( {showHelp && (
+327
View File
@@ -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
View File
@@ -406,7 +406,11 @@ const SECTIONS = [
{ id: 'view', label: 'View Mode' }, { id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' }, { id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' }, { id: 'widgets', label: 'Widgets' },
{ id: 'plots', label: 'Plot Panels' },
{ id: 'signals', label: 'Signals' }, { 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: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' },
]; ];
@@ -510,6 +514,24 @@ function SectionEdit() {
<h3 class="help-h3">Align &amp; distribute</h3> <h3 class="help-h3">Align &amp; 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> <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 &amp; 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> <h3 class="help-h3">Saving</h3>
<ul class="help-list"> <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> <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 &amp; 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 &amp; 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() { function SectionWidgets() {
return ( return (
<div> <div>
@@ -602,7 +759,19 @@ function SectionSignals() {
</p> </p>
<h3 class="help-h3">Synthetic signals</h3> <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> </div>
<DiagramSignalTree /> <DiagramSignalTree />
</div> </div>
@@ -643,6 +812,8 @@ function SectionHistory() {
function SectionShortcuts() { function SectionShortcuts() {
const shortcuts: Array<[string, string]> = [ 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+Z', 'Undo last change (Edit mode)'],
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'], ['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
['Ctrl+A', 'Select all widgets (Edit mode)'], ['Ctrl+A', 'Select all widgets (Edit mode)'],
@@ -674,14 +845,15 @@ function SectionShortcuts() {
<h3 class="help-h3">API &amp; Metrics</h3> <h3 class="help-h3">API &amp; Metrics</h3>
<ul class="help-list"> <ul class="help-list">
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, </li> <li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
<li>Prometheus metrics: <code>/metrics</code></li> <li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
<li>Health check: <code>/healthz</code></li> <li>Identity &amp; groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</code></li>
<li>WebSocket: <code>ws://&lt;host&gt;/ws</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://&lt;host&gt;/ws</code></li>
</ul> </ul>
<h3 class="help-h3">Configuration file</h3> <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> </div>
); );
} }
@@ -691,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
view: SectionView, view: SectionView,
edit: SectionEdit, edit: SectionEdit,
widgets: SectionWidgets, widgets: SectionWidgets,
plots: SectionPlots,
signals: SectionSignals, signals: SectionSignals,
logic: SectionLogic,
control: SectionControl,
sharing: SectionSharing,
history: SectionHistory, history: SectionHistory,
shortcuts: SectionShortcuts, shortcuts: SectionShortcuts,
}; };
+152
View File
@@ -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>
);
}
+217 -43
View File
@@ -1,36 +1,110 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import type { Interface } from './lib/types'; import { useAuth, canWrite } from './lib/auth';
import type { Interface, InterfaceListItem, Folder } from './lib/types';
interface ServerInterface { import ShareDialog from './ShareDialog';
id: string;
name: string;
version: number;
}
interface Props { interface Props {
onLoad: (xml: string) => void; onLoad: (xml: string) => void;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
onNewPlot?: () => void;
onEditId?: (id: string) => void; onEditId?: (id: string) => void;
width?: number;
collapsed: boolean;
onToggleCollapse: () => void;
} }
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) { export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [collapsed, setCollapsed] = useState(false); const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(true); 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 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() { function refresh() {
setLoading(true); setLoading(true);
fetch('/api/v1/interfaces') Promise.all([
.then(r => r.ok ? r.json() : []) fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
.then(data => setInterfaces(data)) 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(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .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() { function triggerImport() {
fileInputRef.current?.click(); fileInputRef.current?.click();
@@ -65,13 +139,105 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
window.open(`?fs=${encodeURIComponent(id)}`, '_blank'); 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 (
<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 ( return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}> <aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header"> <div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>} {!collapsed && <span class="panel-title">Interfaces</span>}
<button <button
class="icon-btn" class="icon-btn"
onClick={() => setCollapsed(c => !c)} onClick={onToggleCollapse}
title={collapsed ? 'Expand panel' : 'Collapse panel'} title={collapsed ? 'Expand panel' : 'Collapse panel'}
> >
{collapsed ? '▶' : '◀'} {collapsed ? '▶' : '◀'}
@@ -79,44 +245,52 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
</div> </div>
{!collapsed && ( {!collapsed && (
<div> <div class="panel-body">
<div class="panel-actions"> {writable && (
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button> <div class="panel-actions">
<button class="panel-btn" onClick={triggerImport}>Import</button> <button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
<input <button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
type="file" <button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
accept=".xml,application/xml,text/xml" <button class="panel-btn" onClick={triggerImport}>Import</button>
style="display:none" <input
ref={fileInputRef} type="file"
onChange={handleFileChange} accept=".xml,application/xml,text/xml"
/> style="display:none"
</div> ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
)}
<div class="panel-list"> <div class="panel-list">
{loading ? ( {loading ? (
<p class="hint">Loading</p> <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> <p class="hint">No interfaces yet. Create one or import XML.</p>
) : ( ) : (
<ul class="iface-list"> <ul
{interfaces.map(iface => ( class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
<li key={iface.id} class="iface-item"> onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}> onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
{iface.name || iface.id} onDrop={(e) => dropInFolder('', e)}
</span> >
<div class="iface-item-actions"> {rootFolders.map(renderFolder)}
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}></button> {rootPanels.map(renderPanel)}
<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> </ul>
)} )}
</div> </div>
</div> </div>
)} )}
{share && (
<ShareDialog
ifaceId={share.id}
ifaceName={share.name}
folders={folders}
onClose={() => setShare(null)}
onSaved={refresh}
/>
)}
</aside> </aside>
); );
} }
+61
View File
@@ -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
+137
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+89 -39
View File
@@ -1,5 +1,5 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types'; import type { Widget, Interface } from './lib/types';
interface Props { interface Props {
@@ -8,6 +8,7 @@ interface Props {
iface: Interface; iface: Interface;
onChange: (updated: Widget) => void; onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void; onIfaceChange: (updated: Interface) => void;
width?: number;
} }
function Field({ label, children }: { label: string; children: any }) { 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 }) { 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) // Sync when external value changes (e.g. different widget selected)
if (local !== value && document.activeElement?.tagName !== 'INPUT') { useEffect(() => {
setLocal(value); setLocal(value || '');
} }, [value]);
return ( return (
<input <input
class="prop-input" class="prop-input"
value={local} value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)} onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={(e) => onCommit((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) // Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']); 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) // Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']); const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
// Widgets with multiple signals // Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']); 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); const [collapsed, setCollapsed] = useState(false);
function setOpt(key: string, value: string) { function setOpt(key: string, value: string) {
@@ -64,7 +75,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
const w = selected; const w = selected;
return ( 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"> <div class="panel-header">
{!collapsed && <span class="panel-title">Properties</span>} {!collapsed && <span class="panel-title">Properties</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}> <button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
@@ -76,35 +87,41 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<div class="props-body"> <div class="props-body">
{/* Canvas-level properties */} {/* Canvas-level properties */}
<div class="props-section"> <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"> <Field label="Name">
<TextInput <TextInput
value={iface.name} value={iface.name}
onCommit={(v) => onIfaceChange({ ...iface, name: v })} onCommit={(v) => onIfaceChange({ ...iface, name: v })}
/> />
</Field> </Field>
<Field label="Width"> {/* Plot panels fill the viewport via the split layout fixed px size
<input is not meaningful, so width/height are hidden. */}
class="prop-input prop-input-num" {iface.kind !== 'plot' && (
type="number" <div>
value={iface.w} <Field label="Width">
min={100} <input
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })} class="prop-input prop-input-num"
/> type="number"
</Field> value={iface.w}
<Field label="Height"> min={100}
<input onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
class="prop-input prop-input-num" />
type="number" </Field>
value={iface.h} <Field label="Height">
min={100} <input
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })} class="prop-input prop-input-num"
/> type="number"
</Field> value={iface.h}
min={100}
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
/>
</Field>
</div>
)}
</div> </div>
{w && ( {w && (
<div class="props-section"> <div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div> <div class="props-section-title">{w.type}</div>
{/* Position / size */} {/* Position / size */}
@@ -154,24 +171,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
signal widgets use it as header label */} signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && ( {w.type !== 'image' && w.type !== 'link' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}> <Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
<TextInput {LABEL_WIDGETS.has(w.type) ? (
value={w.options['label'] ?? ''} <div class="prop-field-col">
onCommit={(v) => setOpt('label', v)} <TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
/> <span class="prop-hint">Empty = signal name</span>
{LABEL_WIDGETS.has(w.type) && ( </div>
<span class="prop-hint">Empty = signal name</span> ) : (
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
)} )}
</Field> </Field>
)} )}
{/* Unit override for signal widgets */} {/* Unit override for signal widgets */}
{UNIT_WIDGETS.has(w.type) && ( {UNIT_WIDGETS.has(w.type) && (
<Field label="Unit override"> <Field label="Unit">
<TextInput <div class="prop-field-col">
value={w.options['unit'] ?? ''} <TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
onCommit={(v) => setOpt('unit', v)} <span class="prop-hint">Empty = from meta · "none" = hide</span>
/> </div>
<span class="prop-hint">Empty = from metadata · "none" = hide</span> </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> </Field>
)} )}
@@ -240,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="true">Yes</option> <option value="true">Yes</option>
</select> </select>
</Field> </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> </div>
)} )}
@@ -271,6 +315,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<Field label="Y max"> <Field label="Y max">
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} /> <TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
</Field> </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"> <Field label="Legend">
<select class="prop-select" value={w.options['legend'] ?? 'bottom'} <select class="prop-select" value={w.options['legend'] ?? 'bottom'}
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}> onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
+58
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+418 -198
View File
@@ -1,7 +1,13 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef } from './lib/types'; import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard'; 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 { interface SignalInfo {
name: string; name: string;
@@ -9,14 +15,17 @@ interface SignalInfo {
unit?: string; unit?: string;
description?: string; description?: string;
writable?: boolean; writable?: boolean;
properties?: Record<string, string>;
tags?: string[];
} }
interface DsGroup { interface DisplayGroup {
ds: string; id: string;
signals: SignalInfo[]; label: string;
signals: Array<SignalInfo & { ds: string }>;
expanded: boolean; expanded: boolean;
addingSignal: boolean; // show add-PV input addingSignal?: boolean;
addValue: string; addValue?: string;
} }
// Custom signals persisted in localStorage // Custom signals persisted in localStorage
@@ -36,41 +45,65 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
interface Props { interface Props {
onDragStart?: (sig: SignalRef) => void; 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 [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 [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [cfAvailable, setCfAvailable] = useState(false); const [cfAvailable, setCfAvailable] = useState(false);
const [cfSearching, setCfSearching] = useState(false);
const [showWizard, setShowWizard] = 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); const csvRef = useRef<HTMLInputElement>(null);
async function loadGroups() { async function loadAllSignals() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/v1/datasources'); const res = await fetch('/api/v1/datasources');
if (!res.ok) return; if (!res.ok) return;
const dsList: { name: string }[] = await res.json(); 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 }) => { dsList.map(async ({ name }) => {
try { try {
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`); // Synthetic signals are filtered server-side by panel scope.
const sigs: SignalInfo[] = r.ok ? await r.json() : []; const url = name === 'synthetic' && panelId
return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' }; ? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}`
} catch { : `/api/v1/signals?ds=${encodeURIComponent(name)}`;
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' }; 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 // Check if Channel Finder is available
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null); const cf = await fetch('/api/v1/channel-finder?q=')
setCfAvailable(cf?.status === 200); .then(r => (r.ok ? r.json() : null))
.catch(() => null);
setCfAvailable(!!cf?.available);
} catch (e) { } catch (e) {
console.error('Failed to load signals:', e); console.error('Failed to load signals:', e);
} finally { } finally {
@@ -78,53 +111,100 @@ export default function SignalTree({ onDragStart }: Props) {
} }
} }
useEffect(() => { loadGroups(); }, []); useEffect(() => { loadAllSignals(); }, [panelId]);
// Merge custom signals into groups // ── Local state variables (edit mode) ──────────────────────────────────────
const allGroups = groups.map(g => { const [showAddLocal, setShowAddLocal] = useState(false);
const extra = customSignals.filter(c => c.ds === g.ds); const [localOpen, setLocalOpen] = useState(true);
const existing = new Set(g.signals.map(s => s.name)); const [lvName, setLvName] = useState('');
const merged = [ const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number');
...g.signals, const [lvInitial, setLvInitial] = useState('0');
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
];
return { ...g, signals: merged };
});
// Custom signals with no matching DS group get their own group function addLocal() {
const knownDs = new Set(groups.map(g => g.ds)); const name = lvName.trim();
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds)); if (!name || !onStateVarsChange) return;
const orphanGroups: DsGroup[] = Object.entries( const next = (statevars ?? []).filter(v => v.name !== name);
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => { next.push({ name, type: lvType, initial: lvInitial });
(acc[ds] ??= []).push(name); onStateVarsChange(next);
return acc; setLvName('');
}, {}) setLvInitial('0');
).map(([ds, names]) => ({ setShowAddLocal(false);
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
expanded: true, addingSignal: false, addValue: '',
}));
const visibleGroups = [...allGroups, ...orphanGroups];
function toggleGroup(ds: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
} }
function setAdding(ds: string, val: boolean) { function removeLocal(name: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g)); if (!onStateVarsChange) return;
onStateVarsChange((statevars ?? []).filter(v => v.name !== name));
} }
function setAddValue(ds: string, val: string) { function makeLocalDraggable(name: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g)); return {
draggable: true as const,
onDragStart: (e: DragEvent) => {
const ref: SignalRef = { ds: 'local', name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
},
};
} }
function commitAdd(ds: string, name: string) { // Merge custom signals that might not be in ListSignals yet
name = name.trim(); const allSignals = useMemo(() => {
if (!name) { setAdding(ds, false); return; } const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`));
const next = [...customSignals, { ds, 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);
});
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]);
function toggleGroup(id: string) {
setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) }));
}
function startAdding(ds: string) {
setAddingTo(ds);
setAddValue('');
setExpandedGroups(prev => ({ ...prev, [ds]: true }));
}
function commitAddManually() {
const name = addValue.trim();
if (!name || !addingTo) { setAddingTo(null); return; }
const next = [...customSignals, { ds: addingTo, name }];
setCustomSignals(next); setCustomSignals(next);
saveCustom(next); saveCustom(next);
setAdding(ds, false); setAddingTo(null);
} }
function removeCustom(ds: string, name: string) { function removeCustom(ds: string, name: string) {
@@ -133,26 +213,21 @@ export default function SignalTree({ onDragStart }: Props) {
saveCustom(next); saveCustom(next);
} }
// ── Channel Finder search ──────────────────────────────────────────────── 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);
loadAllSignals(); // Refresh to fetch metadata for new ones
}
async function handleCfSearch() { function handleManualAdd() {
if (!filter) return; if (!manualName.trim()) return;
setCfSearching(true); handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
try { setManualName('');
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`); setShowManualAdd(false);
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 })),
];
setCustomSignals(next);
saveCustom(next);
} finally {
setCfSearching(false);
}
} }
// ── CSV import ──────────────────────────────────────────────────────────── // ── CSV import ────────────────────────────────────────────────────────────
@@ -169,39 +244,19 @@ export default function SignalTree({ onDragStart }: Props) {
if (!line || line.startsWith('#')) continue; if (!line || line.startsWith('#')) continue;
const parts = line.split(',').map(p => p.trim()); const parts = line.split(',').map(p => p.trim());
if (parts.length === 1 && parts[0]) { if (parts.length === 1 && parts[0]) {
// bare PV name → default to epics
added.push({ ds: 'epics', name: parts[0] }); added.push({ ds: 'epics', name: parts[0] });
} else if (parts.length >= 2 && parts[0] && parts[1]) { } else if (parts.length >= 2 && parts[0] && parts[1]) {
added.push({ ds: parts[0], name: parts[1] }); added.push({ ds: parts[0], name: parts[1] });
} }
} }
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`)); handleAddCfSignals(added);
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
const next = [...customSignals, ...fresh];
setCustomSignals(next);
saveCustom(next);
} }
// ── Filter ──────────────────────────────────────────────────────────────── function makeDraggable(sig: SignalInfo & { ds: string }) {
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) {
return { return {
draggable: true as const, draggable: true as const,
onDragStart: (e: DragEvent) => { 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)); e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref); onDragStart?.(ref);
}, },
@@ -209,7 +264,7 @@ export default function SignalTree({ onDragStart }: Props) {
} }
return ( 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"> <div class="panel-header">
{!collapsed && <span class="panel-title">Signals</span>} {!collapsed && <span class="panel-title">Signals</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}> <button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
@@ -219,117 +274,282 @@ export default function SignalTree({ onDragStart }: Props) {
{!collapsed && ( {!collapsed && (
<div class="signal-tree-body"> <div class="signal-tree-body">
{/* Search / filter bar */} <div class="signal-tree-tabs">
<div class="signal-tree-search"> <button
<div class="signal-search-row"> class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
<input onClick={() => setTreeTab('sources')}
class="signal-filter" >Sources</button>
type="search" <button
placeholder="Filter signals…" class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
value={filter} onClick={() => setTreeTab('groups')}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)} >Groups</button>
/> </div>
{cfAvailable && filter && (
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}> {treeTab === 'groups' && (
{cfSearching ? '…' : '🔍'} <GroupsTree onDragStart={onDragStart} />
</button> )}
{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> </div>
</div> )}
{/* Toolbar: New Synthetic | Import CSV | Refresh */} {treeTab === 'sources' && (
<div class="signal-tree-toolbar"> <Fragment>
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button> <div class="signal-tree-search">
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button> <div class="signal-search-row">
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}></button> <input
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} /> class="signal-filter"
</div> type="search"
placeholder="Filter signals…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
/>
{cfAvailable && (
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
🔍
</button>
)}
</div>
</div>
{/* Signal groups */} <div class="signal-tree-toolbar" style="gap: 4px;">
<div class="panel-list signal-list"> <select
{loading ? ( class="prop-select"
<p class="hint">Loading signals</p> style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
) : filtered.length === 0 ? ( value={groupBy}
<p class="hint">No signals found.</p> onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
) : ( >
filtered.map(group => { <option value="datasource">By Source</option>
const groupInGroups = groups.find(g => g.ds === group.ds); <option value="area">By Area</option>
return ( <option value="system">By System</option>
<div key={group.ds} class="signal-group"> <option value="ioc">By IOC</option>
<div class="signal-group-header"> </select>
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}> <button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
{group.expanded ? '▾' : '▸'} <button class="panel-btn icon-only" title="Create synthetic signal" onClick={() => setShowWizard(true)}>Σ</button>
</span> <button class="panel-btn icon-only" title="Import CSV" onClick={() => csvRef.current?.click()}>CSV</button>
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}> <button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}></button>
{group.ds} <input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
</span> </div>
<span class="signal-group-count">{group.signals.length}</span>
<button
class="icon-btn signal-add-btn"
title={`Add custom signal to ${group.ds}`}
onClick={() => setAdding(group.ds, true)}
>+</button>
</div>
{group.expanded && ( <div class="panel-list signal-list">
<div> {loading ? (
{group.signals.map(sig => { <p class="hint">Loading signals</p>
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name); ) : visibleGroups.length === 0 ? (
return ( <p class="hint">No signals found.</p>
<div ) : (
key={sig.name} visibleGroups.map(group => (
class={`signal-item${isCustom ? ' signal-custom' : ''}`} <div key={group.id} class="signal-group">
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? `${sig.description}` : ''}`} <div class="signal-group-header">
{...makeDraggable(group.ds, sig)} <span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
> {group.expanded ? '▾' : '▸'}
<span class="signal-name">{sig.name}</span> </span>
{sig.unit && <span class="signal-unit">{sig.unit}</span>} <span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
{isCustom && ( {group.label}
<button </span>
class="icon-btn signal-remove-btn" <span class="signal-group-count">{group.signals.length}</span>
title="Remove custom signal" {groupBy === 'datasource' && group.id !== 'synthetic' && (
onMouseDown={(e: MouseEvent) => e.stopPropagation()} <button
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }} class="icon-btn signal-add-btn"
></button> title={`Add custom signal to ${group.id}`}
)} onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
</div> >+</button>
);
})}
{/* Add-signal input row */}
{(groupInGroups?.addingSignal) && (
<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)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
if (e.key === 'Escape') setAdding(group.ds, false);
}}
/>
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}></button>
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}></button>
</div>
)} )}
</div> </div>
)}
</div> {group.expanded && (
); <div>
}) {addingTo === group.id && (
)} <div class="signal-add-row">
</div> <input
class="signal-add-input"
placeholder="Signal / PV name…"
autoFocus
value={addValue}
onInput={(e) => setAddValue((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAddManually();
if (e.key === 'Escape') setAddingTo(null);
}}
/>
<button class="icon-btn" onClick={commitAddManually}></button>
<button class="icon-btn" onClick={() => setAddingTo(null)}></button>
</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> </div>
)} )}
{showWizard && ( {showWizard && (
<SyntheticWizard <SyntheticWizard
currentIfaceId={panelId}
onClose={() => setShowWizard(false)} 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> </aside>
); );
} }
+64
View File
@@ -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>;
}
+582
View File
@@ -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 (ab)', 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 (18)', 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>
);
}
+134 -37
View File
@@ -1,37 +1,58 @@
import { h } from 'preact'; 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 { interface Props {
onClose: () => void; onClose: () => void;
onCreated: () => void; onCreated: () => void;
// Interface id the wizard was opened from — used to bind panel-scoped signals.
currentIfaceId?: string;
} }
interface NodeParam { interface NodeParam {
label: string; label: string;
key: string; key: string;
type: 'number' | 'text'; type: 'number' | 'text' | 'lua';
default: string; default: string;
} }
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ 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: '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: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', 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: 'lowpass', label: 'Low-pass Filter', params: [
{ 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' }] }, { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ type: 'derivative', label: 'Derivative', params: [] }, { label: 'Order (18)', key: 'order', type: 'number', default: '1' },
{ type: 'integral', label: 'Integral', params: [] }, ]},
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'derivative', label: 'Derivative', params: [] },
{ type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] }, { 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: '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 [name, setName] = useState('');
const [inputDs, setInputDs] = useState('stub'); const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [inputSig, setInputSig] = useState('sine_1hz');
const [nodeType, setNodeType] = useState('gain'); const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({}); 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 [unit, setUnit] = useState('');
const [desc, setDesc] = useState(''); const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0'); const [dispLow, setDispLow] = useState('0');
@@ -39,6 +60,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); 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)!; const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
function getParam(key: string, def: string): string { function getParam(key: string, def: string): string {
@@ -49,9 +91,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
setParams(p => ({ ...p, [key]: val })); 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() { async function handleCreate() {
if (!name.trim()) { setError('Name is required'); return; } 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> = {}; const nodeParams: Record<string, any> = {};
for (const p of nodeDef.params) { 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; nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
} }
const def = { const def: SignalDef = {
name: name.trim(), name: name.trim(),
ds: inputDs, inputs,
signal: inputSig.trim(),
pipeline: [{ type: nodeType, params: nodeParams }], pipeline: [{ type: nodeType, params: nodeParams }],
meta: { meta: {
unit: unit || undefined, unit: unit || undefined,
@@ -70,6 +131,8 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
displayLow: parseFloat(dispLow) || 0, displayLow: parseFloat(dispLow) || 0,
displayHigh: parseFloat(dispHigh) || 100, displayHigh: parseFloat(dispHigh) || 100,
}, },
visibility,
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
}; };
setSaving(true); setSaving(true);
@@ -112,23 +175,50 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
onInput={(e) => setName((e.target as HTMLInputElement).value)} /> onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="wizard-section-title">Input signal</div> <div class="wizard-field">
<div class="wizard-field wizard-field-row"> <label>Visibility</label>
<div class="wizard-field"> <select class="prop-select" value={visibility}
<label>Data source</label> onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
<input class="prop-input" value={inputDs} <option value="panel" disabled={!currentIfaceId}>
placeholder="stub" This panel only{currentIfaceId ? '' : ' (save panel first)'}
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} /> </option>
</div> <option value="user">All my panels</option>
<div class="wizard-field" style="flex:2;"> <option value="global">All panels (global)</option>
<label>Signal name</label> </select>
<input class="prop-input" value={inputSig}
placeholder="sine_1hz"
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
</div>
</div> </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"> <div class="wizard-field">
<label>Node type</label> <label>Node type</label>
<select class="prop-select" value={nodeType} <select class="prop-select" value={nodeType}
@@ -139,9 +229,16 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
{nodeDef.params.map(p => ( {nodeDef.params.map(p => (
<div key={p.key} class="wizard-field"> <div key={p.key} class="wizard-field">
<label>{p.label}</label> <label>{p.label}</label>
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'} {p.type === 'lua' ? (
value={getParam(p.key, p.default)} <LuaEditor
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} /> 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> </div>
))} ))}
+85 -11
View File
@@ -4,12 +4,19 @@ import InterfaceList from './InterfaceList';
import Canvas from './Canvas'; import Canvas from './Canvas';
import { wsClient } from './lib/ws'; import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml'; import { parseInterface } from './lib/xml';
import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types'; import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
interface Props { interface Props {
onEdit?: (iface?: Interface) => void; 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) */ /** 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())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
export default function ViewMode({ onEdit }: Props) { export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null); const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
const [parseError, setParseError] = useState<string | null>(null); const [parseError, setParseError] = useState<string | null>(null);
const [wsStatus, setWsStatus] = useState('connecting'); const [wsStatus, setWsStatus] = useState('connecting');
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('start'); const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false); 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); } function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
@@ -39,6 +66,18 @@ export default function ViewMode({ onEdit }: Props) {
return unsub; 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) { function handleLoad(xml: string) {
try { try {
setParseError(null); setParseError(null);
@@ -100,7 +139,7 @@ export default function ViewMode({ onEdit }: Props) {
<div class="toolbar-right"> <div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}> <div class={`status-chip ${wsStatus}`}>
<span class="status-dot"></span> <span class="status-dot"></span>
{wsStatus} {wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
</div> </div>
<button <button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`} class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
@@ -117,13 +156,30 @@ export default function ViewMode({ onEdit }: Props) {
> >
📖 📖
</button> </button>
<button <ZoomControl />
class="btn-edit" {writable && me.canEditLogic && (
onClick={() => onEdit?.(currentInterface ?? undefined)} <button
title="Switch to Edit mode" class="toolbar-btn"
> onClick={() => setShowControlLogic(true)}
Edit title="Open server-side control logic"
</button> >
Control logic
</button>
)}
{writable && (
<button
class="btn-edit"
onClick={() => onEdit?.(currentInterface ?? undefined)}
title="Switch to Edit mode"
>
Edit
</button>
)}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div> </div>
</header> </header>
@@ -166,7 +222,11 @@ export default function ViewMode({ onEdit }: Props) {
<InterfaceList <InterfaceList
onLoad={handleLoad} onLoad={handleLoad}
onEdit={onEdit} onEdit={onEdit}
onNewPlot={() => onEdit?.(newPlotPanel())}
onEditId={handleEditById} onEditId={handleEditById}
width={leftW}
collapsed={listCollapsed}
onToggleCollapse={() => setListCollapsed(c => !c)}
onSelect={async (id) => { onSelect={async (id) => {
try { try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`); 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> </div>
{showHelp && ( {showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} /> <HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)} )}
{showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)}
</div> </div>
); );
} }
+35
View File
@@ -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>
);
}
+44
View File
@@ -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;
}
}
+268
View File
@@ -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