Implemented epics read/write

This commit is contained in:
Martino Ferrari
2026-04-27 12:42:10 +02:00
parent b76b7f0ba8
commit 1bda25454b
32 changed files with 1553 additions and 281 deletions
+38 -5
View File
@@ -42,13 +42,46 @@ $(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
# Build with EPICS Channel Access support. # Build with EPICS Channel Access support.
# Requires EPICS Base and: #
# EPICS_BASE=/path/to/epics/base # Auto-detection: if EPICS_BASE is set in the environment, CGO flags are
# CGO_CFLAGS="-I$$EPICS_BASE/include -I$$EPICS_BASE/include/os/Linux" # derived automatically. Override any variable on the command line:
# CGO_LDFLAGS="-L$$EPICS_BASE/lib/linux-x86_64 -lca -lCom" #
# 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) 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 go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi CGO_ENABLED=1 \
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
CGO_LDFLAGS="$(EPICS_CGO_LDFLAGS)" \
go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
@echo "Built: $(BINARY) (EPICS CA enabled)"
# Unstripped binary for debugging # Unstripped binary for debugging
backend-debug: $(FRONTEND_OUT) backend-debug: $(FRONTEND_OUT)
+13 -11
View File
@@ -59,19 +59,21 @@ func main() {
brk := broker.New(ctx, log) brk := broker.New(ctx, log)
// Stub data source is always registered — it provides synthetic test signals // Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
// used by demo interfaces and unit tests. // Enabled by default; disable via [datasource.stub] enabled = false in config.
stubDS := stub.New() if cfg.Datasource.Stub.Enabled {
if err := stubDS.Connect(ctx); err != nil { stubDS := stub.New()
log.Error("stub connect", "err", err) if err := stubDS.Connect(ctx); err != nil {
os.Exit(1) log.Error("stub connect", "err", err)
os.Exit(1)
}
brk.Register(stubDS)
} }
brk.Register(stubDS)
// Synthetic data source: computes derived signals from upstream sources via // Synthetic data source: computes derived signals from upstream sources via
// configurable DSP pipelines defined in synthetic.json inside the storage dir. // configurable DSP pipelines defined in synthetic.json inside the storage dir.
var synthDS *synthetic.Synthetic var synthDS *synthetic.Synthetic
if cfg.Synthetic.Enabled { if cfg.Datasource.Synthetic.Enabled {
synthDS = synthetic.New(cfg.Server.StorageDir, brk, log) synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
if err := synthDS.Connect(ctx); err != nil { if err := synthDS.Connect(ctx); err != nil {
log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err) log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err)
@@ -83,8 +85,8 @@ func main() {
// EPICS Channel Access data source (requires -tags epics build). // EPICS Channel Access data source (requires -tags epics build).
// epics.Available() returns false when built without the tag. // epics.Available() returns false when built without the tag.
if cfg.EPICS.Enabled && epics.Available() { if cfg.Datasource.EPICS.Enabled && epics.Available() {
ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL) ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL)
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 {
@@ -92,7 +94,7 @@ func main() {
} }
} }
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.EPICS.ChannelFinderURL, log) srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, 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)
+36 -11
View File
@@ -86,23 +86,47 @@ func (b *Broker) Source(name string) (datasource.DataSource, bool) {
// If this is the first subscriber for the signal, the upstream DS subscription // If this is the first subscriber for the signal, the upstream DS subscription
// is started. Subsequent calls for the same signal share the existing one. // is started. Subsequent calls for the same signal share the existing one.
// The returned func must be called when the client no longer needs the signal. // The returned func must be called when the client no longer needs the signal.
//
// The broker mutex is NOT held while the upstream ds.Subscribe call is in
// progress — some data sources (e.g. EPICS) block until the channel connects
// or times out. Releasing the lock allows other goroutines to proceed.
func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) { func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock()
sub, exists := b.subs[ref] sub, exists := b.subs[ref]
if !exists { if exists {
ds, found := b.sources[ref.DS] // Fast path: already subscribed upstream; just add this client.
if !found { sub.mu.Lock()
return nil, fmt.Errorf("unknown data source %q", ref.DS) sub.clients[ch] = struct{}{}
} sub.mu.Unlock()
b.mu.Unlock()
return func() { b.unsubscribe(ref, ch) }, nil
}
rawCh := make(chan datasource.Value, 32) // Slow path: need to start an upstream subscription.
dsCancel, err := ds.Subscribe(b.ctx, ref.Name, rawCh) // Release the lock before the potentially-blocking ds.Subscribe call.
if err != nil { ds, found := b.sources[ref.DS]
return nil, fmt.Errorf("subscribe %s/%s: %w", ref.DS, ref.Name, err) b.mu.Unlock()
}
if !found {
return nil, fmt.Errorf("unknown data source %q", ref.DS)
}
rawCh := make(chan datasource.Value, 32)
dsCancel, err := ds.Subscribe(b.ctx, ref.Name, rawCh)
if err != nil {
return nil, fmt.Errorf("subscribe %s/%s: %w", ref.DS, ref.Name, err)
}
// Re-acquire the lock to register the new subscription.
// Another goroutine might have registered the same ref while the lock
// was released; if so, use that subscription and discard ours.
b.mu.Lock()
if existing, ok := b.subs[ref]; ok {
// Race: someone else subscribed first. Discard duplicate upstream.
dsCancel()
sub = existing
} else {
sub = &signalSub{ sub = &signalSub{
clients: make(map[chan<- Update]struct{}), clients: make(map[chan<- Update]struct{}),
done: make(chan struct{}), done: make(chan struct{}),
@@ -116,6 +140,7 @@ func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
sub.mu.Lock() sub.mu.Lock()
sub.clients[ch] = struct{}{} sub.clients[ch] = struct{}{}
sub.mu.Unlock() sub.mu.Unlock()
b.mu.Unlock()
return func() { b.unsubscribe(ref, ch) }, nil return func() { b.unsubscribe(ref, ch) }, nil
} }
+19 -16
View File
@@ -10,8 +10,7 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
EPICS EPICSConfig `toml:"datasource.epics"` Datasource DatasourceConfig `toml:"datasource"`
Synthetic SyntheticConfig `toml:"datasource.synthetic"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -19,6 +18,16 @@ type ServerConfig struct {
StorageDir string `toml:"storage_dir"` StorageDir string `toml:"storage_dir"`
} }
type DatasourceConfig struct {
Stub StubConfig `toml:"stub"`
EPICS EPICSConfig `toml:"epics"`
Synthetic SyntheticConfig `toml:"synthetic"`
}
type StubConfig struct {
Enabled bool `toml:"enabled"`
}
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"`
@@ -27,8 +36,7 @@ type EPICSConfig struct {
} }
type SyntheticConfig struct { type SyntheticConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
DefinitionsFile string `toml:"definitions_file"`
} }
func Default() Config { func Default() Config {
@@ -37,12 +45,10 @@ func Default() Config {
Listen: ":8080", Listen: ":8080",
StorageDir: "./interfaces", StorageDir: "./interfaces",
}, },
EPICS: EPICSConfig{ Datasource: DatasourceConfig{
Enabled: true, Stub: StubConfig{Enabled: true},
}, EPICS: EPICSConfig{Enabled: true},
Synthetic: SyntheticConfig{ Synthetic: SyntheticConfig{Enabled: true},
Enabled: true,
DefinitionsFile: "./synthetic.json",
}, },
} }
} }
@@ -71,16 +77,13 @@ func applyEnv(cfg *Config) {
cfg.Server.StorageDir = v cfg.Server.StorageDir = v
} }
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" { if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" {
cfg.EPICS.ArchiveURL = v cfg.Datasource.EPICS.ArchiveURL = v
} }
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" { if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
cfg.EPICS.ChannelFinderURL = v cfg.Datasource.EPICS.ChannelFinderURL = v
}
if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" {
cfg.Synthetic.DefinitionsFile = v
} }
} }
+1
View File
@@ -3,6 +3,7 @@
package epics package epics
/* /*
#cgo CFLAGS: -Wno-unused-function
#include "ca_wrapper.h" #include "ca_wrapper.h"
*/ */
import "C" import "C"
+27 -2
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects. * goCAConnectionCallback is called when a channel connects or disconnects.
*/ */
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count, extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
const void *dbr, int severity, void *dbr, int severity,
double epicsTimeSecs); double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected); extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -78,7 +78,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
} }
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count, goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
args.dbr, severity, timeSecs); (void *)args.dbr, severity, timeSecs);
} }
/* /*
@@ -115,4 +115,29 @@ static int caAddMonitor(chid ch, short dbrType, uintptr_t userHandle,
(void *)userHandle, pEvid); (void *)userHandle, pEvid);
} }
/* Wrappers for ca_get / ca_put, which are macros in cadef.h that expand to
* ca_array_get / ca_array_put with count=1. CGo cannot call macros directly,
* so we provide thin static-inline shims here. */
static int caGet(chtype type, chid chan, void *pValue) {
return ca_array_get(type, 1u, chan, pValue);
}
static int caPut(chtype type, chid chan, const void *pValue) {
return ca_array_put(type, 1u, chan, pValue);
}
/* Retrieve the current thread's CA context as an opaque pointer.
* Store the result and pass it to caAttachContext() from other threads. */
static void *caCurrentContext(void) {
return (void *)ca_current_context();
}
/* Attach an existing CA context to the calling thread. Must be called at the
* start of every goroutine (OS thread) that makes CA calls, if that thread did
* not call ca_context_create() itself. Returns ECA_NORMAL on success. */
static int caAttachContext(void *ctx) {
return ca_attach_context((struct ca_client_context *)ctx);
}
#endif /* CA_WRAPPER_H */ #endif /* CA_WRAPPER_H */
+135 -74
View File
@@ -17,7 +17,7 @@
package epics package epics
/* /*
#cgo CFLAGS: -Wall #cgo CFLAGS: -Wall -Wno-unused-function
#include "ca_wrapper.h" #include "ca_wrapper.h"
#include <stdlib.h> #include <stdlib.h>
*/ */
@@ -26,6 +26,7 @@ import "C"
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -68,6 +69,12 @@ type EPICS struct {
caAddrList string caAddrList string
archiveURL string archiveURL string
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
// because the C type (ca_client_context *) is opaque. Every goroutine
// that calls CA functions must call caAttachContext(caCtx) first, because
// Go goroutines can run on any OS thread and CA contexts are thread-local.
caCtx unsafe.Pointer
mu sync.Mutex mu sync.Mutex
channels map[string]*caChannel // PV name → CA channel channels map[string]*caChannel // PV name → CA channel
metadata map[string]datasource.Metadata metadata map[string]datasource.Metadata
@@ -98,27 +105,43 @@ func (e *EPICS) Name() string { return "epics" }
// callbacks are delivered on CA's background threads without needing a // callbacks are delivered on CA's background threads without needing a
// ca_pend_event polling loop. // ca_pend_event polling loop.
func (e *EPICS) Connect(_ context.Context) error { func (e *EPICS) Connect(_ context.Context) error {
// Optionally override EPICS_CA_ADDR_LIST at runtime. // Apply CA address list overrides before creating the context so the
// library picks them up during initialisation.
// os.Setenv is used instead of C.putenv: putenv takes ownership of the
// string pointer, which would race with Go's garbage collector.
if e.caAddrList != "" { if e.caAddrList != "" {
cs := C.CString(e.caAddrList) os.Setenv("EPICS_CA_ADDR_LIST", e.caAddrList)
defer C.free(unsafe.Pointer(cs)) os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
// ca_setenv is not available in all EPICS versions; use putenv via C.
envStr := C.CString("EPICS_CA_ADDR_LIST=" + e.caAddrList)
defer C.free(unsafe.Pointer(envStr))
C.putenv(envStr)
} }
status := C.ca_context_create(C.ca_enable_preemptive_callback) status := C.ca_context_create(C.ca_enable_preemptive_callback)
if status != C.ECA_NORMAL { if status != C.ECA_NORMAL {
return fmt.Errorf("epics: ca_context_create failed: status %d", int(status)) return fmt.Errorf("epics: ca_context_create failed: status %d", int(status))
} }
// Save the context so goroutines on other OS threads can attach to it.
e.caCtx = C.caCurrentContext()
return nil return nil
} }
// Subscribe connects to the named PV (if not already connected), installs a // attachCAContext attaches the saved CA context to the calling OS thread.
// monitor, and streams value updates into ch. The returned CancelFunc removes // Must be called at the top of every goroutine that uses CA functions,
// the monitor and clears the channel. // because Go goroutines can be scheduled onto any OS thread.
// Safe to call redundantly (ECA_ISATTACHED is not an error).
func (e *EPICS) attachCAContext() {
if e.caCtx != nil {
C.caAttachContext(e.caCtx)
}
}
// Subscribe creates a CA channel for signal and returns immediately.
// Connection and monitor setup happen in a background goroutine so that
// the caller is never blocked waiting for the IOC to respond.
// A QualityBad value is sent to ch if the channel cannot connect within
// 60 seconds. The returned CancelFunc tears down the subscription.
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
// Attach the CA context to this OS thread before any CA calls.
e.attachCAContext()
handle := nextHandle() handle := nextHandle()
// Register the handle → channel mapping before creating the CA channel so // Register the handle → channel mapping before creating the CA channel so
@@ -129,7 +152,8 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
connTable[handle] = entry connTable[handle] = entry
handleMu.Unlock() handleMu.Unlock()
// Create the CA channel. // Create the CA channel — this call returns immediately; the connection
// callback fires asynchronously on CA's internal thread.
pvName := C.CString(signal) pvName := C.CString(signal)
defer C.free(unsafe.Pointer(pvName)) defer C.free(unsafe.Pointer(pvName))
@@ -142,54 +166,78 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
handleMu.Unlock() handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status)) return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
} }
// Flush the create request and wait for the connection callback.
C.ca_flush_io() C.ca_flush_io()
select { // Track the channel immediately so Write can find the chid.
case <-entry.connCh:
// Connected.
case <-time.After(5 * time.Second):
// Timeout; clean up and report error.
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: timeout waiting for channel %q to connect", signal)
case <-ctx.Done():
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, ctx.Err()
}
// Determine the native DBR_TIME_* type for the channel.
dbrType := nativeTimeType(chid)
// Add the value monitor.
var evid C.evid
status = C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
if status != C.ECA_NORMAL {
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_add_event(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
caCh := &caChannel{chid: chid, evid: evid, handle: handle}
e.mu.Lock() e.mu.Lock()
e.channels[signal] = caCh e.channels[signal] = &caChannel{chid: chid, handle: handle}
e.mu.Unlock() e.mu.Unlock()
cancel := datasource.CancelFunc(func() { // Use a done channel to signal the goroutine to stop.
if evid != nil { done := make(chan struct{})
var closeOnce sync.Once
stopFn := func() { closeOnce.Do(func() { close(done) }) }
// Background goroutine: wait for connection, then install the monitor.
go func() {
// Attach CA context to this OS thread before making any CA calls.
e.attachCAContext()
var evid C.evid
connected := false
select {
case <-entry.connCh:
connected = true
case <-time.After(60 * time.Second):
// IOC unreachable; report bad quality so the widget shows an error state.
select {
case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}:
default:
}
case <-ctx.Done():
case <-done:
}
if connected {
// Refresh metadata now that the channel is connected so type, unit,
// and display limits are accurate (the earlier sendMeta call may have
// run before the channel was up, leaving an incomplete cache entry).
meta := e.fetchMetadata(chid, signal)
e.mu.Lock()
e.metadata[signal] = meta
e.mu.Unlock()
// Signal to the broker/dispatcher that metadata has been refreshed.
select {
case ch <- datasource.Value{MetaUpdate: true}:
default:
}
dbrType := nativeTimeType(chid)
st := C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
if st != C.ECA_NORMAL {
select {
case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}:
default:
}
connected = false
} else {
C.ca_flush_io()
e.mu.Lock()
e.channels[signal] = &caChannel{chid: chid, evid: evid, handle: handle}
e.mu.Unlock()
}
}
// Wait until the subscription is cancelled.
select {
case <-done:
case <-ctx.Done():
}
// Cleanup.
if connected && evid != nil {
C.ca_clear_event(evid) C.ca_clear_event(evid)
} }
C.ca_clear_channel(chid) C.ca_clear_channel(chid)
@@ -203,15 +251,18 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
e.mu.Lock() e.mu.Lock()
delete(e.channels, signal) delete(e.channels, signal)
e.mu.Unlock() e.mu.Unlock()
}) }()
return cancel, nil return datasource.CancelFunc(stopFn), nil
} }
// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the // GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
// appropriate control type) to retrieve units, display limits, enum strings, // appropriate control type) to retrieve units, display limits, enum strings,
// and writability information for the named signal. // and writability information for the named signal.
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) { func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
// Attach the CA context to this OS thread before any CA calls.
e.attachCAContext()
// Check cache first. // Check cache first.
e.mu.Lock() e.mu.Lock()
if m, ok := e.metadata[signal]; ok { if m, ok := e.metadata[signal]; ok {
@@ -289,7 +340,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 {
meta := datasource.Metadata{Name: name} // In EPICS, write access is governed by CA security (host/user rules), not
// by the field type. Default to writable=true; the IOC will reject puts
// that violate its security policy.
meta := datasource.Metadata{Name: name, Writable: true}
fieldType := C.ca_field_type(chid) fieldType := C.ca_field_type(chid)
count := C.ca_element_count(chid) count := C.ca_element_count(chid)
@@ -326,7 +380,7 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
switch ctrlType { switch ctrlType {
case C.DBR_CTRL_DOUBLE: case C.DBR_CTRL_DOUBLE:
var buf C.struct_dbr_ctrl_double var buf C.struct_dbr_ctrl_double
status := C.ca_get(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf)) status := C.caGet(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL { if status == C.ECA_NORMAL {
C.ca_pend_io(3.0) C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0]))) meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
@@ -334,14 +388,11 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
meta.DisplayHigh = float64(buf.upper_disp_limit) meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit) meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit) meta.DriveHigh = float64(buf.upper_ctrl_limit)
// CA does not expose a writable flag directly; assume writable unless
// it is a read-only field type. Conservatively mark as writable.
meta.Writable = true
} }
case C.DBR_CTRL_LONG: case C.DBR_CTRL_LONG:
var buf C.struct_dbr_ctrl_long var buf C.struct_dbr_ctrl_long
status := C.ca_get(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf)) status := C.caGet(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL { if status == C.ECA_NORMAL {
C.ca_pend_io(3.0) C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0]))) meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
@@ -349,12 +400,11 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
meta.DisplayHigh = float64(buf.upper_disp_limit) meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit) meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit) meta.DriveHigh = float64(buf.upper_ctrl_limit)
meta.Writable = true
} }
case C.DBR_CTRL_ENUM: case C.DBR_CTRL_ENUM:
var buf C.struct_dbr_ctrl_enum var buf C.struct_dbr_ctrl_enum
status := C.ca_get(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf)) status := C.caGet(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL { if status == C.ECA_NORMAL {
C.ca_pend_io(3.0) C.ca_pend_io(3.0)
n := int(buf.no_str) n := int(buf.no_str)
@@ -363,28 +413,39 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0]))) strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0])))
} }
meta.EnumStrings = strs meta.EnumStrings = strs
meta.Writable = true
} }
} }
return meta return meta
} }
// ListSignals returns cached metadata for all currently connected channels. // ListSignals returns metadata for all currently tracked channels.
// Full enumeration of all available PVs is deferred to Phase 9. // 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()
out := make([]datasource.Metadata, 0, len(e.metadata)) // Start with all fully-fetched metadata entries.
for _, m := range e.metadata { out := make([]datasource.Metadata, 0, len(e.channels))
seen := make(map[string]bool, len(e.metadata))
for name, m := range e.metadata {
out = append(out, m) out = append(out, m)
seen[name] = true
}
// Add stub entries for channels that are tracked but not yet fetched.
for name := range e.channels {
if !seen[name] {
out = append(out, datasource.Metadata{Name: name, Type: datasource.TypeFloat64})
}
} }
return out, nil return out, nil
} }
// Write puts a new value onto a CA channel. // Write puts a new value onto a CA channel.
func (e *EPICS) Write(_ context.Context, signal string, value any) error { func (e *EPICS) Write(_ context.Context, signal string, value any) error {
e.attachCAContext()
e.mu.Lock() e.mu.Lock()
caCh, ok := e.channels[signal] caCh, ok := e.channels[signal]
e.mu.Unlock() e.mu.Unlock()
@@ -396,20 +457,20 @@ func (e *EPICS) Write(_ context.Context, signal string, value any) error {
switch v := value.(type) { switch v := value.(type) {
case float64: case float64:
cv := C.double(v) cv := C.double(v)
status = C.ca_put(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv)) status = C.caPut(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv))
case int64: case int64:
cv := C.long(v) cv := C.long(v)
status = C.ca_put(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv)) status = C.caPut(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv))
case string: case string:
cs := C.CString(v) cs := C.CString(v)
defer C.free(unsafe.Pointer(cs)) defer C.free(unsafe.Pointer(cs))
status = C.ca_put(C.DBR_STRING, caCh.chid, unsafe.Pointer(cs)) status = C.caPut(C.DBR_STRING, caCh.chid, unsafe.Pointer(cs))
case bool: case bool:
var iv C.short var iv C.short
if v { if v {
iv = 1 iv = 1
} }
status = C.ca_put(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv)) status = C.caPut(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv))
default: default:
return fmt.Errorf("epics: unsupported value type %T for Write", value) return fmt.Errorf("epics: unsupported value type %T for Write", value)
} }
+4 -3
View File
@@ -42,9 +42,10 @@ const (
// Value is a timestamped signal reading. // Value is a timestamped signal reading.
type Value struct { type Value struct {
Timestamp time.Time Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool Data any // float64 | []float64 | string | int64 | bool
Quality Quality Quality Quality
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
} }
// Metadata describes the static properties of a signal. // Metadata describes the static properties of a signal.
+12
View File
@@ -163,6 +163,12 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
for { for {
select { select {
case u := <-c.updateCh: case u := <-c.updateCh:
if u.Value.MetaUpdate {
// Datasource refreshed metadata (e.g. after channel connected).
// Re-send the meta message so the client gets accurate type/unit info.
c.sendMeta(ctx, u.Ref)
continue
}
msg := outMsg{ msg := outMsg{
Type: "update", Type: "update",
DS: u.Ref.DS, DS: u.Ref.DS,
@@ -265,6 +271,7 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
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.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS) c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return return
} }
@@ -272,10 +279,12 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Enforce write permission before touching the value payload. // Enforce write permission before touching the value payload.
meta, err := ds.GetMetadata(ctx, msg.Name) meta, err := ds.GetMetadata(ctx, msg.Name)
if err != nil { if err != nil {
c.log.Warn("write: GetMetadata failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name) c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name)
return return
} }
if !meta.Writable { if !meta.Writable {
c.log.Warn("write: signal not writable", "ds", msg.DS, "name", msg.Name)
c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name) c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name)
return return
} }
@@ -283,12 +292,15 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Decode the JSON value as the appropriate Go type. // Decode the JSON value as the appropriate Go type.
var value any var value any
if err := json.Unmarshal(msg.Value, &value); err != nil { if err := json.Unmarshal(msg.Value, &value); err != nil {
c.log.Warn("write: parse error", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error()) c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error())
return return
} }
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
metrics.IncWrites() metrics.IncWrites()
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.sendError(ctx, "WRITE_ERROR", err.Error()) c.sendError(ctx, "WRITE_ERROR", err.Error())
} }
} }
BIN
View File
Binary file not shown.
+9 -1
View File
@@ -3,10 +3,14 @@ import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws'; 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 type { Interface } from './lib/types'; import type { Interface } from './lib/types';
type AppMode = 'view' | 'edit'; type AppMode = 'view' | 'edit';
// If URL has ?fs=<id>, render bare fullscreen canvas
const fsParam = new URLSearchParams(window.location.search).get('fs');
export default function App() { 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');
@@ -20,9 +24,13 @@ 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));
wsClient.connect('/ws'); if (!fsParam) wsClient.connect('/ws');
}, []); }, []);
if (fsParam) {
return <FullscreenMode interfaceId={fsParam} />;
}
function enterEdit(iface?: Interface) { function enterEdit(iface?: Interface) {
setEditTarget(iface ?? null); setEditTarget(iface ?? null);
setMode('edit'); setMode('edit');
+101 -15
View File
@@ -37,6 +37,11 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
link: [140, 50], link: [140, 50],
}; };
// Widget types that support multiple signals (signal can be appended)
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
// Widget types that have exactly one signal (signal can be replaced)
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
interface PickerState { interface PickerState {
visible: boolean; visible: boolean;
x: number; x: number;
@@ -46,6 +51,14 @@ interface PickerState {
signal: SignalRef | null; signal: SignalRef | null;
} }
interface SignalDropMenuState {
visible: boolean;
x: number;
y: number;
signal: SignalRef;
targetWidget: Widget;
}
interface DragState { interface DragState {
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
@@ -65,9 +78,9 @@ interface ResizeState {
interface RubberBand { interface RubberBand {
active: boolean; active: boolean;
startX: number; startY: number; // canvas-relative startX: number; startY: number;
curX: number; curY: number; curX: number; curY: number;
clientStartX: number; clientStartY: number; // for scroll adjustment clientStartX: number; clientStartY: number;
} }
interface Props { interface Props {
@@ -75,7 +88,7 @@ interface Props {
selectedIds: string[]; selectedIds: string[];
onSelect: (ids: string[]) => void; onSelect: (ids: string[]) => void;
onChange: (widgets: Widget[]) => void; onChange: (widgets: Widget[]) => void;
snapGrid: number; // 0 = off snapGrid: number;
} }
let _widgetSeq = Date.now(); let _widgetSeq = Date.now();
@@ -95,7 +108,6 @@ function rectsIntersect(
export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) { export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Refs: always hold current values so event handlers don't go stale
const ifaceRef = useRef(iface); const ifaceRef = useRef(iface);
ifaceRef.current = iface; ifaceRef.current = iface;
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
@@ -111,14 +123,14 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const resizeRef = useRef<ResizeState | null>(null); const resizeRef = useRef<ResizeState | null>(null);
const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 }); const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 });
// Rubber-band visual state (drives re-renders during drag)
const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [picker, setPicker] = useState<PickerState>({ const [picker, setPicker] = useState<PickerState>({
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null, visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
}); });
// Set up document-level mouse handlers once const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
useEffect(() => { useEffect(() => {
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current; const grid = snapGridRef.current;
@@ -213,7 +225,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
function handleCanvasMouseDown(e: MouseEvent) { function handleCanvasMouseDown(e: MouseEvent) {
if ((e.target as Element).closest('.widget-overlay')) return; if ((e.target as Element).closest('.widget-overlay')) return;
if ((e.target as Element).closest('.widget-picker-backdrop')) return; if ((e.target as Element).closest('.widget-picker-backdrop')) return;
// Start rubber-band on empty canvas if ((e.target as Element).closest('.signal-drop-backdrop')) return;
const { cx, cy } = getCanvasPos(e); const { cx, cy } = getCanvasPos(e);
rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY }; rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY };
if (!e.ctrlKey && !e.metaKey) { if (!e.ctrlKey && !e.metaKey) {
@@ -232,10 +244,50 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
if (!json) return; if (!json) return;
let sig: SignalRef; let sig: SignalRef;
try { sig = JSON.parse(json); } catch { return; } try { sig = JSON.parse(json); } catch { return; }
const { cx, cy } = getCanvasPos(e); const { cx, cy } = getCanvasPos(e);
// Check if drop lands on an existing widget
const hit = iface.widgets.find(w =>
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
);
if (hit && (MULTI_SIGNAL_TYPES.has(hit.type) || SINGLE_SIGNAL_TYPES.has(hit.type))) {
// Offer add/replace vs create-new
setSignalDropMenu({ visible: true, x: e.clientX, y: e.clientY, signal: sig, targetWidget: hit });
return;
}
// No existing widget hit — open type picker
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig }); setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
} }
function handleAddToWidget(widget: Widget, sig: SignalRef) {
setSignalDropMenu(null);
let updated: Widget;
if (MULTI_SIGNAL_TYPES.has(widget.type)) {
// Append signal (avoid duplicates)
const already = widget.signals.some(s => s.ds === sig.ds && s.name === sig.name);
if (already) return;
updated = { ...widget, signals: [...widget.signals, sig] };
} else {
// Replace first signal
updated = { ...widget, signals: [sig, ...widget.signals.slice(1)] };
}
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w));
onSelect([updated.id]);
}
function handleSignalDropCreateNew(sig: SignalRef, cx: number, cy: number) {
setSignalDropMenu(null);
// We don't have canvas coords in the menu state, approximate from screen coords
const el = containerRef.current;
const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 };
const canvasX = cx - rect.left + (el?.scrollLeft ?? 0);
const canvasY = cy - rect.top + (el?.scrollTop ?? 0);
setPicker({ visible: true, x: cx, y: cy, canvasX, canvasY, signal: sig });
}
function handlePick(type: string) { function handlePick(type: string) {
if (!picker.signal) return; if (!picker.signal) return;
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60]; const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
@@ -255,10 +307,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
setPicker(p => ({ ...p, visible: false })); setPicker(p => ({ ...p, visible: false }));
} }
// Called from EditMode when inserting a static widget (no signal)
// This is triggered via the parent placing a widget directly.
// (handled via onInsert prop in extended usage)
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) { function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
e.stopPropagation(); e.stopPropagation();
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
@@ -274,7 +322,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
} }
onSelect(nextIds); onSelect(nextIds);
// Don't start drag on Ctrl+click deselect
if (ctrl && isSelected) return; if (ctrl && isSelected) return;
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id]; const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
@@ -317,7 +364,6 @@ 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;`}>
{/* Live widget previews */}
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
return Comp return Comp
@@ -330,7 +376,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
); );
})} })}
{/* Interaction overlays */}
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const isSelected = selectedIds.includes(widget.id); const isSelected = selectedIds.includes(widget.id);
return ( return (
@@ -361,7 +406,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
); );
})} })}
{/* Rubber-band selection rectangle */}
{rubberVis && ( {rubberVis && (
<div <div
class="rubber-band" class="rubber-band"
@@ -378,6 +422,48 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
onCancel={() => setPicker(p => ({ ...p, visible: false }))} onCancel={() => setPicker(p => ({ ...p, visible: false }))}
/> />
)} )}
{/* Signal drop menu: appears when dropping onto an existing widget */}
{signalDropMenu && (
<div class="signal-drop-backdrop" onClick={() => setSignalDropMenu(null)}>
<div
class="signal-drop-menu"
style={`left:${signalDropMenu.x}px;top:${signalDropMenu.y}px;`}
onClick={(e: MouseEvent) => e.stopPropagation()}
>
<div class="signal-drop-header">
Drop signal <strong>{signalDropMenu.signal.name}</strong>
</div>
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
<button
class="ctx-item"
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
>
+ Add signal to this {signalDropMenu.targetWidget.type}
</button>
)}
{SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
<button
class="ctx-item"
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
>
Replace signal in this {signalDropMenu.targetWidget.type}
</button>
)}
<button
class="ctx-item"
onClick={() => handleSignalDropCreateNew(
signalDropMenu.signal,
signalDropMenu.x,
signalDropMenu.y,
)}
>
+ Create new widget
</button>
<button class="ctx-item ctx-cancel" onClick={() => setSignalDropMenu(null)}>Cancel</button>
</div>
</div>
)}
</div> </div>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml';
import type { Interface } from './lib/types';
import Canvas from './Canvas';
interface Props {
interfaceId: string;
}
export default function FullscreenMode({ interfaceId }: Props) {
const [iface, setIface] = useState<Interface | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
wsClient.connect('/ws');
fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(xml => setIface(parseInterface(xml)))
.catch(err => setError(err instanceof Error ? err.message : String(err)));
}, [interfaceId]);
if (error) {
return (
<div class="fs-error">
<p>Failed to load interface: {error}</p>
</div>
);
}
return (
<div class="fs-view">
<Canvas iface={iface} onNavigate={async (id) => {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setIface(parseInterface(await res.text()));
} catch { /* ignore */ }
}} timeRange={null} />
</div>
);
}
+8 -2
View File
@@ -12,9 +12,10 @@ 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;
onEditId?: (id: string) => void;
} }
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) { export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]); const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -60,6 +61,10 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
if (res.ok) refresh(); if (res.ok) refresh();
} }
function handleFullscreen(id: string) {
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
}
return ( return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}> <aside class={`panel${collapsed ? ' collapsed' : ''}`}>
<div class="panel-header"> <div class="panel-header">
@@ -100,7 +105,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
{iface.name || iface.id} {iface.name || iface.id}
</span> </span>
<div class="iface-item-actions"> <div class="iface-item-actions">
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}></button> <button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}></button>
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}></button>
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button> <button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button>
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button> <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button>
</div> </div>
+175 -53
View File
@@ -4,7 +4,7 @@ import type { Widget, Interface } from './lib/types';
interface Props { interface Props {
selected: Widget | null; selected: Widget | null;
multiCount: number; // >0 when multiple widgets are selected multiCount: number;
iface: Interface; iface: Interface;
onChange: (updated: Widget) => void; onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void; onIfaceChange: (updated: Interface) => void;
@@ -21,6 +21,10 @@ 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)
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
setLocal(value);
}
return ( return (
<input <input
class="prop-input" class="prop-input"
@@ -31,6 +35,13 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
); );
} }
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) { export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -44,6 +55,14 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
onChange({ ...selected, [key]: value } as Widget); onChange({ ...selected, [key]: value } as Widget);
} }
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
onChange({ ...selected, signals });
}
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;">
<div class="panel-header"> <div class="panel-header">
@@ -55,7 +74,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{!collapsed && ( {!collapsed && (
<div class="props-body"> <div class="props-body">
{/* Interface-level properties (always shown) */} {/* Canvas-level properties */}
<div class="props-section"> <div class="props-section">
<div class="props-section-title">Canvas</div> <div class="props-section-title">Canvas</div>
<Field label="Name"> <Field label="Name">
@@ -84,117 +103,220 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field> </Field>
</div> </div>
{selected && ( {w && (
<div class="props-section"> <div class="props-section">
<div class="props-section-title">{selected.type}</div> <div class="props-section-title">{w.type}</div>
{/* Position / size */}
<Field label="X"> <Field label="X">
<input class="prop-input prop-input-num" type="number" value={selected.x} <input class="prop-input prop-input-num" type="number" value={w.x}
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} /> onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field> </Field>
<Field label="Y"> <Field label="Y">
<input class="prop-input prop-input-num" type="number" value={selected.y} <input class="prop-input prop-input-num" type="number" value={w.y}
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} /> onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field> </Field>
<Field label="Width"> <Field label="Width">
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10} <input class="prop-input prop-input-num" type="number" value={w.w} min={10}
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} /> onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field> </Field>
<Field label="Height"> <Field label="Height">
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10} <input class="prop-input prop-input-num" type="number" value={w.h} min={10}
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} /> onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field> </Field>
{/* Signal */} {/* Signals */}
{selected.signals.length > 0 && ( {MULTI_SIGNAL_WIDGETS.has(w.type) ? (
<Field label="Signals">
<div class="prop-signals-list">
{w.signals.length === 0 && <span class="hint">No signals</span>}
{w.signals.map((s, i) => (
<div key={i} class="prop-signal-row">
<span class="prop-sig">{s.ds}:{s.name}</span>
<button
class="icon-btn prop-sig-remove"
title="Remove signal"
onClick={() => removeSignal(i)}
></button>
</div>
))}
</div>
</Field>
) : w.signals.length > 0 ? (
<Field label="Signal"> <Field label="Signal">
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span> <span class="prop-sig">{w.signals[0].ds}:{w.signals[0].name}</span>
</Field>
) : null}
{/* ── Common label/unit overrides (issue #8) ──────────────────── */}
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
<TextInput
value={w.options['label'] ?? ''}
onCommit={(v) => setOpt('label', v)}
/>
{LABEL_WIDGETS.has(w.type) && (
<span class="prop-hint">Empty = signal name</span>
)}
</Field> </Field>
)} )}
{/* Common options */} {/* Unit override for signal widgets */}
<Field label="Label"> {UNIT_WIDGETS.has(w.type) && (
<TextInput <Field label="Unit override">
value={selected.options['label'] ?? ''} <TextInput
onCommit={(v) => setOpt('label', v)} value={w.options['unit'] ?? ''}
/> onCommit={(v) => setOpt('unit', v)}
</Field> />
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
</Field>
)}
{/* Type-specific options */} {/* ── Type-specific options ──────────────────────────────────── */}
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
<div> <div>
<Field label="Min"> <Field label="Min">
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} /> <TextInput value={w.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
</Field> </Field>
<Field label="Max"> <Field label="Max">
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} /> <TextInput value={w.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
</Field> </Field>
</div> </div>
)} )}
{selected.type === 'led' && ( {w.type === 'led' && (
<Field label="Condition"> <Field label="Condition">
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} /> <TextInput value={w.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
</Field> </Field>
)} )}
{selected.type === 'setvalue' && ( {w.type === 'setvalue' && (
<Field label="Min"> <div>
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} /> <Field label="Min">
</Field> <TextInput value={w.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)} )}
{selected.type === 'plot' && ( {/* Button options (issue #2) */}
<Field label="Plot type"> {w.type === 'button' && (
<select <div>
class="prop-select" <Field label="Send value">
value={selected.options['plotType'] ?? 'timeseries'} <TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)} </Field>
> <Field label="Mode">
<option value="timeseries">Time-series</option> <select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
<option value="histogram">Histogram</option> onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
<option value="bar">Bar chart</option> <option value="oneshot">One-shot (set once)</option>
<option value="fft">FFT</option> <option value="setReset">Set-reset (auto-revert)</option>
<option value="waterfall">Waterfall</option> <option value="persistent">Persistent (toggle / monitor)</option>
<option value="logic">Logic analyser</option> </select>
</select> </Field>
</Field> {(w.options['mode'] === 'setReset' || w.options['mode'] === 'persistent') && (
<Field label="Reset value">
<TextInput value={w.options['resetValue'] ?? '0'} onCommit={(v) => setOpt('resetValue', v)} />
</Field>
)}
{w.options['mode'] === 'setReset' && (
<Field label="Reset after (s)">
<TextInput value={w.options['resetTime'] ?? '1'} onCommit={(v) => setOpt('resetTime', v)} />
</Field>
)}
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)} )}
{selected.type === 'textlabel' && ( {/* Plot options */}
{w.type === 'plot' && (
<div>
<Field label="Plot type">
<select
class="prop-select"
value={w.options['plotType'] ?? 'timeseries'}
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
>
<option value="timeseries">Time-series</option>
<option value="histogram">Histogram</option>
<option value="bar">Bar chart</option>
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
<Field label="Time window (s)">
<TextInput value={w.options['timeWindow'] ?? '60'} onCommit={(v) => setOpt('timeWindow', v)} />
</Field>
)}
<Field label="Y min">
<TextInput value={w.options['yMin'] ?? 'auto'} onCommit={(v) => setOpt('yMin', v)} />
</Field>
<Field label="Y max">
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
</Field>
<Field label="Legend">
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
<option value="bottom">Bottom</option>
<option value="top">Top</option>
<option value="none">None</option>
</select>
</Field>
</div>
)}
{w.type === 'textlabel' && (
<div> <div>
<Field label="Font size"> <Field label="Font size">
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} /> <TextInput value={w.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
</Field> </Field>
<Field label="Color"> <Field label="Color">
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} /> <TextInput value={w.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
</Field> </Field>
</div> </div>
)} )}
{selected.type === 'image' && ( {w.type === 'image' && (
<Field label="URL"> <Field label="URL">
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} /> <TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
</Field> </Field>
)} )}
{selected.type === 'link' && ( {w.type === 'link' && (
<div> <div>
<Field label="Target"> <Field label="Target">
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} /> <TextInput value={w.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
</Field> </Field>
<Field label="Label"> <Field label="Label">
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} /> <TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
</Field> </Field>
</div> </div>
)} )}
</div> </div>
)} )}
{!selected && multiCount > 1 && ( {!w && multiCount > 1 && (
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p> <p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
)} )}
{!selected && multiCount === 0 && ( {!w && multiCount === 0 && (
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p> <p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
)} )}
</div> </div>
+58 -36
View File
@@ -1,11 +1,10 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks'; import { useState, useMemo, useEffect } from 'preact/hooks';
import InterfaceList from './InterfaceList'; 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 type { Interface } from './lib/types'; import type { Interface } from './lib/types';
import { useEffect } from 'preact/hooks';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
@@ -25,6 +24,7 @@ export default function ViewMode({ onEdit }: Props) {
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);
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); } function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
@@ -52,13 +52,24 @@ export default function ViewMode({ onEdit }: Props) {
try { try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`); const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const xml = await res.text(); handleLoad(await res.text());
handleLoad(xml);
} catch (err) { } catch (err) {
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`); setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
} }
} }
/** Load interface by ID and pass parsed object to onEdit */
async function handleEditById(id: string) {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const iface = parseInterface(await res.text());
onEdit?.(iface);
} catch (err) {
setParseError(`Failed to load interface for editing: ${err instanceof Error ? err.message : err}`);
}
}
function handleLoadHistory() { function handleLoadHistory() {
if (!startInput || !endInput) return; if (!startInput || !endInput) return;
const start = new Date(startInput).toISOString(); const start = new Date(startInput).toISOString();
@@ -82,38 +93,6 @@ export default function ViewMode({ onEdit }: Props) {
)} )}
</div> </div>
<div class="toolbar-center"> <div class="toolbar-center">
{/* Time range picker */}
<div class="time-nav">
<button
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
title="Switch to live data"
onClick={handleGoLive}
>
Live
</button>
<input
class="time-input"
type="datetime-local"
value={startInput}
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
title="History start"
/>
<span class="time-sep"></span>
<input
class="time-input"
type="datetime-local"
value={endInput}
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
title="History end"
/>
<button
class="toolbar-btn"
title="Load historical data for this time range"
onClick={handleLoadHistory}
>
Load
</button>
</div>
{parseError && ( {parseError && (
<span class="parse-error" title={parseError}>Parse error check console</span> <span class="parse-error" title={parseError}>Parse error check console</span>
)} )}
@@ -123,6 +102,13 @@ export default function ViewMode({ onEdit }: Props) {
<span class="status-dot"></span> <span class="status-dot"></span>
{wsStatus} {wsStatus}
</div> </div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
title="Toggle historical time navigation"
onClick={() => setShowTimeNav(v => !v)}
>
History
</button>
<ContextualHelp mode="view" onOpenManual={openHelp} /> <ContextualHelp mode="view" onOpenManual={openHelp} />
<button <button
class="icon-btn help-manual-btn" class="icon-btn help-manual-btn"
@@ -141,10 +127,46 @@ export default function ViewMode({ onEdit }: Props) {
</div> </div>
</header> </header>
{/* Historical time range bar — hidden by default */}
{showTimeNav && (
<div class="time-nav-bar">
<button
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
title="Switch to live data"
onClick={handleGoLive}
>
Live
</button>
<input
class="time-input"
type="datetime-local"
value={startInput}
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
title="History start"
/>
<span class="time-sep"></span>
<input
class="time-input"
type="datetime-local"
value={endInput}
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
title="History end"
/>
<button
class="toolbar-btn"
title="Load historical data for this time range"
onClick={handleLoadHistory}
>
Load
</button>
</div>
)}
<div class="content"> <div class="content">
<InterfaceList <InterfaceList
onLoad={handleLoad} onLoad={handleLoad}
onEdit={onEdit} onEdit={onEdit}
onEditId={handleEditById}
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)}`);
+1
View File
@@ -259,6 +259,7 @@ class WsClient {
} }
write(ref: SignalRef, value: any): void { write(ref: SignalRef, value: any): void {
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
} }
} }
+134 -27
View File
@@ -994,33 +994,7 @@ body {
border: 1px solid #3b82f6; border: 1px solid #3b82f6;
} }
/* ── Time range / history navigation ──────────────────────────────────────── */ /* time-nav styles are in the time-nav-bar section at the bottom of this file */
.time-nav {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-input {
font-size: 0.75rem;
background: #1a2236;
border: 1px solid #2d3748;
border-radius: 4px;
color: #cbd5e1;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
.time-input:focus { outline: none; border-color: #63b3ed; }
/* Colour-scheme hint for the browser's native date-time picker */
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
.time-sep {
color: #475569;
font-size: 0.8rem;
}
/* Overlay shown inside a plot widget while history is loading or unavailable */ /* Overlay shown inside a plot widget while history is loading or unavailable */
.hist-overlay { .hist-overlay {
@@ -1966,3 +1940,136 @@ kbd {
} }
.ctx-help-link:hover { text-decoration: underline; } .ctx-help-link:hover { text-decoration: underline; }
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
.time-nav-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 1rem;
background: #161b27;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
flex-wrap: wrap;
}
.time-input {
font-size: 0.75rem;
background: #1a2236;
border: 1px solid #2d3748;
color: #e2e8f0;
border-radius: 4px;
padding: 0.25rem 0.4rem;
cursor: pointer;
height: 28px;
box-sizing: border-box;
}
.time-input:focus { outline: none; border-color: #63b3ed; }
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
.time-sep {
color: #475569;
font-size: 0.8rem;
}
/* ── Fullscreen mode ──────────────────────────────────────────────────────── */
.fs-view {
width: 100vw;
height: 100vh;
overflow: auto;
background: #0f1117;
display: flex;
}
.fs-error {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #f87171;
font-size: 1rem;
}
/* ── Signal drop menu (drop onto existing widget) ─────────────────────────── */
.signal-drop-backdrop {
position: absolute;
inset: 0;
z-index: 9500;
}
.signal-drop-menu {
position: fixed;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
min-width: 220px;
z-index: 9600;
overflow: hidden;
transform: translateY(4px);
}
.signal-drop-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: #64748b;
border-bottom: 1px solid #2d3748;
}
.signal-drop-header strong {
color: #94a3b8;
}
.ctx-cancel {
color: #6b7280;
border-top: 1px solid #2d3748;
}
/* ── Button widget: pressed / mode hint ──────────────────────────────────── */
.btn-pressed {
background: #1e40af !important;
border-color: #3b82f6 !important;
color: #93c5fd !important;
}
.btn-mode-hint {
font-size: 0.65rem;
opacity: 0.7;
margin-left: 0.35rem;
}
/* ── PropertiesPane: signals list + hint text ─────────────────────────────── */
.prop-signals-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.prop-signal-row {
display: flex;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.prop-sig-remove {
font-size: 0.65rem;
opacity: 0.6;
flex-shrink: 0;
}
.prop-sig-remove:hover { opacity: 1; color: #f87171; }
.prop-hint {
display: block;
font-size: 0.65rem;
color: #475569;
margin-top: 0.15rem;
line-height: 1.2;
}
+3 -2
View File
@@ -28,8 +28,9 @@ export default function BarH({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); }; return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]); }, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? ''; const label = widget.options['label'] || sigRef?.name || '';
const unit = widget.options['unit'] ?? meta?.unit ?? ''; const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0)); const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100)); const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality; const quality = sv.quality;
+3 -2
View File
@@ -28,8 +28,9 @@ export default function BarV({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); }; return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]); }, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? ''; const label = widget.options['label'] || sigRef?.name || '';
const unit = widget.options['unit'] ?? meta?.unit ?? ''; const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0)); const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100)); const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality; const quality = sv.quality;
+50 -10
View File
@@ -1,24 +1,59 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { getSignalStore } from '../lib/stores';
import type { Widget } from '../lib/types'; import type { Widget } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; } interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function Button({ widget, onContextMenu }: Props) { export default function Button({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0]; const sigRef = widget.signals[0];
const label = widget.options['label'] ?? 'Button'; const label = widget.options['label'] ?? 'Button';
const valueOpt = widget.options['value'] ?? '1'; const sendValue = widget.options['value'] ?? '1';
const confirm = widget.options['confirm'] === 'true'; const resetValue = widget.options['resetValue'] ?? '0';
// mode: oneshot | setReset | persistent
const mode = widget.options['mode'] ?? 'oneshot';
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
const confirmOpt = widget.options['confirm'] === 'true';
const [signalVal, setSignalVal] = useState<any>(null);
// Subscribe to signal only for persistent mode (to track whether it is active)
useEffect(() => {
if (!sigRef || mode !== 'persistent') return;
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
}, [sigRef?.ds, sigRef?.name, mode]);
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
// Persistent mode: button appears pressed while signal value matches sendValue
const isPressed = mode === 'persistent'
&& signalVal !== null
&& String(signalVal) === String(parsedSend);
function doWrite(val: any) {
if (!sigRef) return;
wsClient.write(sigRef, val);
}
function execute() {
if (mode === 'setReset') {
doWrite(parsedSend);
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
} else if (mode === 'persistent') {
doWrite(isPressed ? parsedReset : parsedSend);
} else {
doWrite(parsedSend);
}
}
function handleClick() { function handleClick() {
if (!sigRef) return; if (!sigRef) return;
const parsed = parseFloat(valueOpt); if (confirmOpt) {
const val = isNaN(parsed) ? valueOpt : parsed; if (window.confirm(`Send ${label}?`)) execute();
const doWrite = () => wsClient.write(sigRef, val);
if (confirm) {
if (window.confirm(`Send ${label}?`)) doWrite();
} else { } else {
doWrite(); execute();
} }
} }
@@ -28,7 +63,12 @@ export default function Button({ widget, onContextMenu }: Props) {
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;`}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<button class="btn" onClick={handleClick}>{label}</button> <button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
{label}
{mode === 'setReset' && (
<span class="btn-mode-hint">{resetTimeSec}s</span>
)}
</button>
</div> </div>
); );
} }
+3 -2
View File
@@ -51,8 +51,9 @@ export default function Gauge({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); }; return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]); }, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? ''; const label = widget.options['label'] || sigRef?.name || '';
const unit = widget.options['unit'] ?? meta?.unit ?? ''; const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0)); const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100)); const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null; const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
+4 -4
View File
@@ -64,15 +64,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle'); const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Re-render chart when switching between live and historical mode. // Stable primitive deps so the effect re-runs when plotType or signals change.
// We stringify timeRange so the effect dependency is a stable primitive.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live'; const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
const plotType = widget.options['plotType'] ?? 'timeseries';
const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
const signals = widget.signals; const signals = widget.signals;
const plotType = widget.options['plotType'] ?? 'timeseries';
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60'); const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
const yMin = widget.options['yMin']; const yMin = widget.options['yMin'];
const yMax = widget.options['yMax']; const yMax = widget.options['yMax'];
@@ -329,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy(); if (uplot) uplot.destroy();
if (echart) echart.dispose(); if (echart) echart.dispose();
}; };
}, [widget.id, timeRangeKey]); }, [widget.id, timeRangeKey, plotType, signalsKey]);
return ( return (
<div <div
+4 -3
View File
@@ -30,10 +30,11 @@ export default function SetValue({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); }; return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]); }, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? ''; const label = widget.options['label'] || sigRef?.name || '';
const unit = widget.options['unit'] ?? meta?.unit ?? ''; const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const confirm = widget.options['confirm'] === 'true'; const confirm = widget.options['confirm'] === 'true';
const isNumeric = meta ? meta.type !== 'TypeString' : true; const isNumeric = meta ? meta.type !== 'string' : true;
const quality = sv.quality; const quality = sv.quality;
function displayValue(): string { function displayValue(): string {
+3 -2
View File
@@ -28,8 +28,9 @@ export default function TextView({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); }; return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]); }, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? ''; const label = widget.options['label'] || sigRef?.name || '';
const unit = widget.options['unit'] ?? meta?.unit ?? ''; const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const quality = sv.quality; const quality = sv.quality;
function displayValue(): string { function displayValue(): string {
+224
View File
@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
uopi EPICS test panel
Covers: textview · gauge · barh · barv · led · multiled · setvalue · button ×3 modes · plot ×4 types
Signals: epics (UOPI:*) · synthetic (temp_moving_avg, temp_derivative, noise_lowpass, etc.)
Canvas: 1380 × 720
-->
<interface id="epics_test" name="EPICS Test Panel" version="1" width="1380" height="720">
<!-- ════════════════════════ SECTION LABELS ════════════════════════════ -->
<widget type="textlabel" id="lbl_monitor" x="10" y="6" w="160" h="22">
<option key="label" value="PROCESS VALUES"/>
<option key="fontSize" value="11"/>
<option key="color" value="#64748b"/>
</widget>
<widget type="textlabel" id="lbl_controls" x="10" y="302" w="120" h="22">
<option key="label" value="CONTROLS"/>
<option key="fontSize" value="11"/>
<option key="color" value="#64748b"/>
</widget>
<widget type="textlabel" id="lbl_trends" x="10" y="392" w="120" h="22">
<option key="label" value="TRENDS &amp; ANALYSIS"/>
<option key="fontSize" value="11"/>
<option key="color" value="#64748b"/>
</widget>
<!-- ════════════════════════ TEXT VIEWS ══════════════════════════════ -->
<!-- textview: quality dot, live value, unit from EPICS metadata -->
<!-- Temperature: analog float, alarm chain LOW/HIGH/LOLO/HIHI -->
<widget type="textview" id="tv_temp" x="10" y="28" w="220" h="58">
<signal ds="epics" name="UOPI:TEMP"/>
<option key="label" value="Temperature"/>
</widget>
<!-- Pressure: sawtooth, different unit (bar) -->
<widget type="textview" id="tv_pressure" x="244" y="28" w="210" h="58">
<signal ds="epics" name="UOPI:PRESSURE"/>
<option key="label" value="Pressure"/>
</widget>
<!-- Flow: noisy signal, 5 Hz update -->
<widget type="textview" id="tv_flow" x="468" y="28" w="210" h="58">
<signal ds="epics" name="UOPI:FLOW"/>
<option key="label" value="Flow Rate"/>
</widget>
<!-- Counter: monotonically increasing integer -->
<widget type="textview" id="tv_counter" x="692" y="28" w="200" h="58">
<signal ds="epics" name="UOPI:COUNTER"/>
<option key="label" value="Event Counter"/>
<option key="unit" value="none"/>
</widget>
<!-- Message: string PV — tests string display and write -->
<widget type="textview" id="tv_message" x="906" y="28" w="320" h="58">
<signal ds="epics" name="UOPI:MESSAGE"/>
<option key="label" value="Operator Msg"/>
</widget>
<!-- ════════════════════════ GAUGES ══════════════════════════════════ -->
<!-- gauge: SVG arc, displays LOPR/HOPR range, alarm colour zones -->
<!-- Temperature gauge: full alarm chain visible as colour zones -->
<widget type="gauge" id="g_temp" x="10" y="100" w="170" h="170">
<signal ds="epics" name="UOPI:TEMP"/>
<option key="label" value="Temperature"/>
<option key="min" value="0"/>
<option key="max" value="100"/>
<option key="thresholdHigh" value="78"/>
<option key="thresholdLow" value="20"/>
</widget>
<!-- Pressure gauge -->
<widget type="gauge" id="g_pressure" x="194" y="100" w="150" h="150">
<signal ds="epics" name="UOPI:PRESSURE"/>
<option key="label" value="Pressure"/>
<option key="min" value="0"/>
<option key="max" value="10"/>
<option key="thresholdHigh" value="8"/>
</widget>
<!-- ════════════════════════ BAR WIDGETS ═════════════════════════════ -->
<!-- Temperature horizontal bar: full-range with setpoint reference -->
<widget type="barh" id="bh_temp" x="360" y="100" w="300" h="52">
<signal ds="epics" name="UOPI:TEMP"/>
<option key="label" value="Temperature"/>
<option key="min" value="0"/>
<option key="max" value="100"/>
</widget>
<!-- Deviation from setpoint: symmetric range, can go negative -->
<widget type="barh" id="bh_dev" x="360" y="166" w="300" h="52">
<signal ds="epics" name="UOPI:TEMP_DEVIATION"/>
<option key="label" value="ΔT from SP"/>
<option key="min" value="-60"/>
<option key="max" value="60"/>
</widget>
<!-- Flow vertical bar -->
<widget type="barv" id="bv_flow" x="360" y="232" w="50" h="56">
<signal ds="epics" name="UOPI:FLOW"/>
<option key="label" value="Flow"/>
<option key="min" value="0"/>
<option key="max" value="100"/>
</widget>
<!-- ════════════════════════ LED INDICATORS ══════════════════════════ -->
<!-- Beam On/Off LED — binary bo PV, tests ZNAM/ONAM string display -->
<widget type="led" id="led_beam" x="676" y="100" w="90" h="90">
<signal ds="epics" name="UOPI:BEAM_ON"/>
<option key="label" value="Beam On"/>
<option key="condition" value="value>0"/>
</widget>
<!-- Interlock LED — computed calc, MAJOR alarm when fired -->
<widget type="led" id="led_ilck" x="780" y="100" w="90" h="90">
<signal ds="epics" name="UOPI:INTERLOCK"/>
<option key="label" value="Interlock"/>
<option key="condition" value="value>0"/>
</widget>
<!-- ════════════════════════ MULTI-LED ═══════════════════════════════ -->
<!-- multiled: bitset display — STATUS_BITS is an 8-bit rotating word -->
<widget type="multiled" id="ml_status" x="676" y="204" w="360" h="64">
<signal ds="epics" name="UOPI:STATUS_BITS"/>
<option key="label" value="Status Bits"/>
<option key="bits" value="8"/>
</widget>
<!-- ════════════════════════ CONTROLS ════════════════════════════════ -->
<!-- Setpoint: ao PV, numeric write, unit from EPICS metadata -->
<widget type="setvalue" id="sv_sp" x="10" y="324" w="240" h="56">
<signal ds="epics" name="UOPI:SETPOINT"/>
<option key="label" value="Temp Setpoint"/>
</widget>
<!-- Mode selector: mbbo enum, write 0=Idle 1=Running 2=Fault 3=Maint -->
<widget type="setvalue" id="sv_mode" x="264" y="324" w="220" h="56">
<signal ds="epics" name="UOPI:MODE"/>
<option key="label" value="Mode (03)"/>
<option key="unit" value="none"/>
</widget>
<!-- String write: stringout PV, freeform text -->
<widget type="setvalue" id="sv_msg" x="498" y="324" w="280" h="56">
<signal ds="epics" name="UOPI:MESSAGE"/>
<option key="label" value="Operator Message"/>
<option key="unit" value="none"/>
</widget>
<!-- Button ONE-SHOT: writes 1 to RESET_CMD; IOC auto-resets after 1 s -->
<widget type="button" id="btn_reset" x="794" y="324" w="160" h="56">
<signal ds="epics" name="UOPI:RESET_CMD"/>
<option key="label" value="Reset"/>
<option key="value" value="1"/>
<option key="mode" value="oneshot"/>
</widget>
<!-- Button SET-RESET: writes 1, then uopi reverts to 0 after 2 s -->
<widget type="button" id="btn_latch" x="968" y="324" w="200" h="56">
<signal ds="epics" name="UOPI:LATCH"/>
<option key="label" value="Latch (2 s)"/>
<option key="value" value="1"/>
<option key="resetValue" value="0"/>
<option key="resetTime" value="2"/>
<option key="mode" value="setReset"/>
</widget>
<!-- Button PERSISTENT: toggles BEAM_ON; stays pressed while value=1 -->
<widget type="button" id="btn_beam" x="1182" y="324" w="186" h="56">
<signal ds="epics" name="UOPI:BEAM_ON"/>
<option key="label" value="Beam Enable"/>
<option key="value" value="1"/>
<option key="resetValue" value="0"/>
<option key="mode" value="persistent"/>
</widget>
<!-- ════════════════════════ PLOTS ═══════════════════════════════════ -->
<!-- Timeseries: TEMP + moving average + derivative (3 signals, synthetic) -->
<widget type="plot" id="plot_temp" x="10" y="414" w="480" h="290">
<signal ds="epics" name="UOPI:TEMP"/>
<signal ds="synthetic" name="temp_moving_avg"/>
<signal ds="synthetic" name="temp_derivative"/>
<option key="plotType" value="timeseries"/>
<option key="timeWindow" value="120"/>
<option key="yMin" value="auto"/>
<option key="yMax" value="auto"/>
<option key="legend" value="bottom"/>
</widget>
<!-- Histogram: noise distribution (expect uniform 01) -->
<widget type="plot" id="plot_hist" x="504" y="414" w="290" h="290">
<signal ds="epics" name="UOPI:NOISE"/>
<option key="plotType" value="histogram"/>
<option key="legend" value="none"/>
</widget>
<!-- Timeseries: raw noise vs lowpass filtered (synthetic) -->
<widget type="plot" id="plot_noise" x="808" y="414" w="380" h="290">
<signal ds="epics" name="UOPI:NOISE"/>
<signal ds="synthetic" name="noise_lowpass"/>
<option key="plotType" value="timeseries"/>
<option key="timeWindow" value="30"/>
<option key="legend" value="bottom"/>
</widget>
<!-- Pressure sawtooth — bar chart (latest value per signal) -->
<widget type="plot" id="plot_bar" x="1202" y="414" w="166" h="290">
<signal ds="epics" name="UOPI:PRESSURE"/>
<signal ds="epics" name="UOPI:FLOW"/>
<option key="plotType" value="bar"/>
<option key="legend" value="bottom"/>
</widget>
</interface>
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# workspace/run.sh — start softIOC + uopi for EPICS workspace testing
#
# Usage (from project root):
# bash workspace/run.sh
#
# Requirements:
# • EPICS Base installed (EPICS_BASE set, or auto-detected below)
# • softIoc binary available
# • uopi built with EPICS support (script builds it if missing)
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKSPACE="$PROJECT_ROOT/workspace"
BINARY="$PROJECT_ROOT/dist/uopi"
# ── Locate EPICS Base ─────────────────────────────────────────────────────────
if [ -z "${EPICS_BASE:-}" ]; then
for candidate in \
/usr/lib/epics \
/usr/local/epics \
/opt/epics/base \
/opt/epics; do
if [ -f "$candidate/include/cadef.h" ]; then
export EPICS_BASE="$candidate"
break
fi
done
fi
if [ -z "${EPICS_BASE:-}" ]; then
echo "ERROR: EPICS_BASE is not set and could not be auto-detected."
echo " export EPICS_BASE=/path/to/epics/base"
exit 1
fi
# Detect architecture
EPICS_ARCH="${EPICS_ARCH:-$(ls "$EPICS_BASE/lib" | grep linux | head -1)}"
if [ -z "$EPICS_ARCH" ]; then
EPICS_ARCH="linux-x86_64"
fi
# ── Environment ───────────────────────────────────────────────────────────────
# Point CA exclusively at the local IOC — no broadcast scanning.
export EPICS_CA_ADDR_LIST="127.0.0.1"
export EPICS_CA_AUTO_ADDR_LIST="NO"
# Ensure the EPICS shared libraries are found at runtime.
export LD_LIBRARY_PATH="$EPICS_BASE/lib/$EPICS_ARCH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
# Make EPICS tools (softIoc, caget, caput…) available.
export PATH="$EPICS_BASE/bin/$EPICS_ARCH:$PATH"
# ── Locate softIoc ────────────────────────────────────────────────────────────
if ! command -v softIoc &>/dev/null; then
echo "ERROR: softIoc not found in $EPICS_BASE/bin/$EPICS_ARCH or PATH."
exit 1
fi
# ── Build uopi ────────────────────────────────────────────────────────────────
echo "Building uopi with EPICS support..."
cd "$PROJECT_ROOT"
make backend-epics 2>&1
echo ""
# ── Cleanup handler ───────────────────────────────────────────────────────────
PIDS=()
cleanup() {
echo ""
echo "Shutting down..."
for pid in "${PIDS[@]:-}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# ── Kill any stale softIOC from a previous run ────────────────────────────────
pkill -x softIoc 2>/dev/null || true
# Give the OS a moment to release the CA port (5064/tcp+udp).
sleep 0.3
# ── Start softIOC ─────────────────────────────────────────────────────────────
echo "Starting softIOC..."
softIoc -S -d "$WORKSPACE/softioc/uopi_test.db" \
>"$WORKSPACE/softioc.log" 2>&1 &
IOC_PID=$!
PIDS+=("$IOC_PID")
# Wait for the IOC to accept Channel Access connections (up to 10 s).
echo -n "Waiting for IOC to be ready"
for i in $(seq 1 20); do
if caget -w 1 UOPI:TICK >/dev/null 2>&1; then
echo " OK"
break
fi
echo -n "."
sleep 0.5
if [ "$i" -eq 20 ]; then
echo ""
echo "ERROR: IOC did not become ready within 10 seconds."
echo "Check $WORKSPACE/softioc.log for details."
exit 1
fi
done
# ── Start uopi ────────────────────────────────────────────────────────────────
echo "Starting uopi (http://localhost:8080)..."
echo " Open the browser, switch to Edit mode, load workspace/data/epics_test.xml"
echo " or select it from the interface list in View mode."
echo " Press Ctrl-C to stop."
echo ""
cd "$PROJECT_ROOT"
"$BINARY" -config "$WORKSPACE/uopi.toml"
+2
View File
@@ -0,0 +1,2 @@
Starting iocInit
iocRun: All initialization complete
View File
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════════
# uopi test SoftIOC startup script
#
# Prerequisites:
# • EPICS Base 3.15+ (for RNDM calc function)
# • 'softIoc' binary in PATH (usually $EPICS_BASE/bin/$EPICS_HOST_ARCH/)
# • Optional: caput (from EPICS tools) to pre-fill UOPI:WAVEFORM
#
# Usage:
# cd workspace/softioc
# ./st.cmd
#
# Or directly (no waveform pre-fill):
# softIoc -s -d workspace/softioc/uopi_test.db
# ═══════════════════════════════════════════════════════════════════════════
set -euo pipefail
cd "$(dirname "$0")"
# ── Locate softIoc ────────────────────────────────────────────────────────────
if ! command -v softIoc &>/dev/null; then
# Try common EPICS install paths
for candidate in \
"$EPICS_BASE/bin/$EPICS_HOST_ARCH/softIoc" \
/usr/local/epics/base/bin/linux-x86_64/softIoc \
/opt/epics/bin/linux-x86_64/softIoc; do
if [ -x "$candidate" ]; then
export PATH="$(dirname "$candidate"):$PATH"
break
fi
done
fi
if ! command -v softIoc &>/dev/null; then
echo "ERROR: softIoc not found. Set EPICS_BASE or add softIoc to PATH."
exit 1
fi
echo "Starting uopi test IOC…"
echo " PV prefix : UOPI:"
echo " Database : uopi_test.db"
echo " softIoc : $(command -v softIoc)"
echo ""
# ── Start IOC in background so we can run caput for waveform ─────────────────
softIoc -s -d uopi_test.db &
IOC_PID=$!
# ── Pre-fill UOPI:WAVEFORM with a sine+DC pattern (requires caput) ───────────
if command -v caput &>/dev/null; then
sleep 2 # wait for IOC to initialise Channel Access
python3 - <<'PYEOF'
import subprocess, math
n = 64
# Sine with 3 harmonics: models a realistic ADC capture
vals = [
0.5 + 0.4 * math.sin(2 * math.pi * i / n)
+ 0.1 * math.sin(6 * math.pi * i / n)
+ 0.05 * math.sin(10 * math.pi * i / n)
for i in range(n)
]
args = ["caput", "-a", "UOPI:WAVEFORM", str(n)] + [f"{v:.6f}" for v in vals]
result = subprocess.run(args, capture_output=True, text=True)
if result.returncode == 0:
print(f" UOPI:WAVEFORM filled with {n}-point multi-harmonic sine.")
else:
print(f" caput failed: {result.stderr.strip()}")
PYEOF
else
echo " (caput not found — UOPI:WAVEFORM left empty; fill manually via caput)"
fi
echo ""
echo "IOC running (PID $IOC_PID). Connect uopi with:"
echo " UOPI_EPICS_CA_ADDR_LIST=127.0.0.1 ./dist/uopi -config workspace/uopi.toml"
echo ""
echo "Press Ctrl-C to stop."
wait "$IOC_PID"
+231
View File
@@ -0,0 +1,231 @@
# ═══════════════════════════════════════════════════════════════════════════
# uopi test IOC database — covers all widget types and EPICS features
#
# Requirements: EPICS Base 3.15+ (uses RNDM calc function for noise)
# Prefix: UOPI:
#
# Run with: softIoc -s -d uopi_test.db
# ═══════════════════════════════════════════════════════════════════════════
# ── Phase counter ────────────────────────────────────────────────────────────
# Increments 10x/s; used as phase source for trig-based simulations.
# At 0.1s scan, 1 step = 0.1 rad → period 2π/step·rate = 62.8 s.
record(calc, "UOPI:TICK") {
field(DESC, "Phase counter (0.1 s)")
field(SCAN, ".1 second")
field(CALC, "A+0.1")
field(INPA, "UOPI:TICK NPP NMS")
field(VAL, "0")
field(PREC, "2")
}
# ── Analog read-only signals ─────────────────────────────────────────────────
# Temperature: sine 1090 °C, 62.8 s period, full alarm chain
# Tests: textview, gauge (color zones), barh, plot timeseries, quality dot
record(calc, "UOPI:TEMP") {
field(DESC, "Simulated temperature")
field(SCAN, ".1 second")
field(CALC, "50+40*SIN(A)")
field(INPA, "UOPI:TICK NPP NMS")
field(EGU, "degC")
field(PREC, "2")
field(LOPR, "0")
field(HOPR, "100")
field(LOW, "20")
field(HIGH, "78")
field(LOLO, "12")
field(HIHI, "88")
field(LSV, "MINOR")
field(HSV, "MINOR")
field(LLSV, "MAJOR")
field(HHSV, "MAJOR")
}
# Pressure: sawtooth 010 bar, ~20 s period
# Tests: gauge, barh, different EGU
record(calc, "UOPI:PRESSURE") {
field(DESC, "Simulated pressure")
field(SCAN, ".5 second")
field(CALC, "10*((A%6.2832)/6.2832)")
field(INPA, "UOPI:TICK NPP NMS")
field(EGU, "bar")
field(PREC, "3")
field(LOPR, "0")
field(HOPR, "10")
field(HIGH, "8")
field(HIHI, "9.5")
field(HSV, "MINOR")
field(HHSV, "MAJOR")
}
# Flow rate: noisy sine 2080 L/min, faster period (10.5 s), 5 Hz update
# Tests: barv, different scan rate, noise in plot, textview with unit
record(calc, "UOPI:FLOW") {
field(DESC, "Simulated flow rate")
field(SCAN, ".2 second")
field(CALC, "50+30*SIN(A*6)+5*RNDM")
field(INPA, "UOPI:TICK NPP NMS")
field(EGU, "L/min")
field(PREC, "1")
field(LOPR, "0")
field(HOPR, "100")
field(LOW, "10")
field(LOLO, "5")
field(LSV, "MINOR")
field(LLSV, "MAJOR")
}
# Noise signal: pure random 01, 10 Hz
# Tests: plot histogram, plot FFT, fast timeseries, synthetic lowpass
record(calc, "UOPI:NOISE") {
field(DESC, "Random noise signal")
field(SCAN, ".1 second")
field(CALC, "RNDM")
field(PREC, "4")
field(LOPR, "0")
field(HOPR, "1")
}
# Auto-incrementing integer counter, 1 Hz
# Tests: textview integer, plot (monotonic), different numeric type
record(calc, "UOPI:COUNTER") {
field(DESC, "Event counter (1 Hz)")
field(SCAN, "1 second")
field(CALC, "A+1")
field(INPA, "UOPI:COUNTER NPP NMS")
field(VAL, "0")
field(LOPR, "0")
field(HOPR, "100000")
}
# Temperature deviation from setpoint
# Tests: barh with negative range, alarm on deviation, computed read-only
record(calc, "UOPI:TEMP_DEVIATION") {
field(DESC, "Temp deviation from setpoint")
field(SCAN, "1 second")
field(CALC, "A-B")
field(INPA, "UOPI:TEMP NPP NMS")
field(INPB, "UOPI:SETPOINT NPP NMS")
field(EGU, "degC")
field(PREC, "2")
field(LOPR, "-60")
field(HOPR, "60")
field(HIGH, "10")
field(HIHI, "25")
field(LOW, "-10")
field(LOLO, "-25")
field(HSV, "MINOR")
field(HHSV, "MAJOR")
field(LSV, "MINOR")
field(LLSV, "MAJOR")
}
# ── Binary / status signals ───────────────────────────────────────────────────
# Interlock: fires when temperature exceeds 80 °C — MAJOR alarm
# Tests: LED with condition, quality=bad on alarm, computed binary
record(calc, "UOPI:INTERLOCK") {
field(DESC, "High-temp interlock")
field(SCAN, "1 second")
field(CALC, "A>80?1:0")
field(INPA, "UOPI:TEMP NPP NMS")
field(HIHI, "1")
field(HHSV, "MAJOR")
field(LOPR, "0")
field(HOPR, "1")
}
# 8-bit status word: rotating single-bit pattern every 2 s
# Tests: Multi-LED widget (bitset display), integer signal
record(calc, "UOPI:STATUS_BITS") {
field(DESC, "8-bit status word")
field(SCAN, "2 second")
field(CALC, "A>=128?(A*2-255):(A*2)")
field(INPA, "UOPI:STATUS_BITS NPP NMS")
field(VAL, "1")
field(LOPR, "0")
field(HOPR, "255")
}
# ── Read-write signals ────────────────────────────────────────────────────────
# Analog output setpoint — writable, drives UOPI:TEMP_DEVIATION
# Tests: setvalue widget (numeric write), unit from metadata
record(ao, "UOPI:SETPOINT") {
field(DESC, "Temperature setpoint")
field(EGU, "degC")
field(PREC, "1")
field(LOPR, "0")
field(HOPR, "100")
field(DRVL, "0")
field(DRVH, "100")
field(VAL, "50")
field(PINI, "YES")
}
# Beam enable: binary On/Off toggle
# Tests: LED display, button (persistent mode — tracks signal state)
record(bo, "UOPI:BEAM_ON") {
field(DESC, "Beam enable")
field(ZNAM, "Off")
field(ONAM, "On")
field(VAL, "0")
field(PINI, "YES")
}
# Reset command: writes 1 and auto-reverts to 0 after 1 s (IOC-side pulse)
# Tests: button one-shot mode — IOC handles the reset automatically
record(bo, "UOPI:RESET_CMD") {
field(DESC, "Reset command (auto-clears after 1 s)")
field(ZNAM, "Idle")
field(ONAM, "Reset!")
field(HIGH, "1.0")
}
# Latch: plain writable binary — no auto-reset
# Tests: button set-reset mode — uopi writes 1 then resets after delay
record(bo, "UOPI:LATCH") {
field(DESC, "Latch enable")
field(ZNAM, "Released")
field(ONAM, "Latched")
field(VAL, "0")
field(PINI, "YES")
}
# Operating mode: 4-state enum (writable)
# Tests: setvalue (numeric write to enum), textview (shows numeric state)
# multi-LED style readback via setvalue, alarm state on Fault
record(mbbo, "UOPI:MODE") {
field(DESC, "System operating mode")
field(ZRST, "Idle")
field(ONST, "Running")
field(TWST, "Fault")
field(THST, "Maintenance")
field(ZRVL, "0")
field(ONVL, "1")
field(TWVL, "2")
field(THVL, "3")
field(TWSV, "MAJOR")
field(THSV, "MINOR")
field(VAL, "0")
field(PINI, "YES")
}
# String message: writable freeform text
# Tests: setvalue with string type, textview string display
record(stringout, "UOPI:MESSAGE") {
field(DESC, "Operator message (writable)")
field(VAL, "System nominal")
field(PINI, "YES")
}
# Waveform: 128-point float array
# Tests: plot FFT, plot histogram over array (populated at IOC start)
record(waveform, "UOPI:WAVEFORM") {
field(DESC, "64-point sample waveform")
field(FTVL, "FLOAT")
field(NELM, "64")
field(EGU, "counts")
}
+4
View File
@@ -0,0 +1,4 @@
time=2026-04-27T00:05:22.337+02:00 level=INFO msg="data source registered" name=synthetic
time=2026-04-27T00:05:22.337+02:00 level=INFO msg="data source registered" name=epics
time=2026-04-27T00:05:22.337+02:00 level=INFO msg=listening addr=:8080
time=2026-04-27T00:05:24.711+02:00 level=INFO msg="shutting down"