Implemented epics read/write
This commit is contained in:
@@ -42,13 +42,46 @@ $(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
|
||||
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||
|
||||
# Build with EPICS Channel Access support.
|
||||
# Requires EPICS Base and:
|
||||
# EPICS_BASE=/path/to/epics/base
|
||||
# CGO_CFLAGS="-I$$EPICS_BASE/include -I$$EPICS_BASE/include/os/Linux"
|
||||
# CGO_LDFLAGS="-L$$EPICS_BASE/lib/linux-x86_64 -lca -lCom"
|
||||
#
|
||||
# Auto-detection: if EPICS_BASE is set in the environment, CGO flags are
|
||||
# derived automatically. Override any variable on the command line:
|
||||
#
|
||||
# make backend-epics EPICS_BASE=/path/to/epics/base EPICS_ARCH=linux-x86_64
|
||||
#
|
||||
# If EPICS_BASE is not set, set it to the directory that contains
|
||||
# include/cadef.h and lib/<arch>/libca.so.
|
||||
|
||||
EPICS_BASE ?= $(EPICS_BASE)
|
||||
|
||||
# Detect host arch from EPICS startup script if not provided.
|
||||
EPICS_ARCH ?= $(shell \
|
||||
if [ -x "$(EPICS_BASE)/startup/EpicsHostArch" ]; then \
|
||||
"$(EPICS_BASE)/startup/EpicsHostArch"; \
|
||||
elif [ -x "$(EPICS_BASE)/lib" ]; then \
|
||||
ls "$(EPICS_BASE)/lib" | grep linux | head -1; \
|
||||
else \
|
||||
echo "linux-x86_64"; \
|
||||
fi)
|
||||
|
||||
EPICS_CGO_CFLAGS ?= -I$(EPICS_BASE)/include -I$(EPICS_BASE)/include/os/Linux -I$(EPICS_BASE)/include/compiler/gcc
|
||||
EPICS_CGO_LDFLAGS ?= -L$(EPICS_BASE)/lib/$(EPICS_ARCH) -lca -lCom -Wl,-rpath,$(EPICS_BASE)/lib/$(EPICS_ARCH)
|
||||
|
||||
backend-epics: $(FRONTEND_OUT)
|
||||
@if [ -z "$(EPICS_BASE)" ]; then \
|
||||
echo "ERROR: EPICS_BASE is not set."; \
|
||||
echo " export EPICS_BASE=/path/to/epics/base"; \
|
||||
echo " then re-run: make backend-epics"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building with EPICS support"
|
||||
@echo " EPICS_BASE = $(EPICS_BASE)"
|
||||
@echo " EPICS_ARCH = $(EPICS_ARCH)"
|
||||
@mkdir -p dist
|
||||
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
|
||||
backend-debug: $(FRONTEND_OUT)
|
||||
|
||||
+13
-11
@@ -59,19 +59,21 @@ func main() {
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
|
||||
// Stub data source is always registered — it provides synthetic test signals
|
||||
// used by demo interfaces and unit tests.
|
||||
stubDS := stub.New()
|
||||
if err := stubDS.Connect(ctx); err != nil {
|
||||
log.Error("stub connect", "err", err)
|
||||
os.Exit(1)
|
||||
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
|
||||
// Enabled by default; disable via [datasource.stub] enabled = false in config.
|
||||
if cfg.Datasource.Stub.Enabled {
|
||||
stubDS := stub.New()
|
||||
if err := stubDS.Connect(ctx); err != nil {
|
||||
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
|
||||
// configurable DSP pipelines defined in synthetic.json inside the storage dir.
|
||||
var synthDS *synthetic.Synthetic
|
||||
if cfg.Synthetic.Enabled {
|
||||
if cfg.Datasource.Synthetic.Enabled {
|
||||
synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
|
||||
if err := synthDS.Connect(ctx); err != nil {
|
||||
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.Available() returns false when built without the tag.
|
||||
if cfg.EPICS.Enabled && epics.Available() {
|
||||
ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL)
|
||||
if cfg.Datasource.EPICS.Enabled && epics.Available() {
|
||||
ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
log.Error("epics connect", "err", err)
|
||||
} 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 {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
|
||||
+36
-11
@@ -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
|
||||
// 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 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) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
sub, exists := b.subs[ref]
|
||||
if !exists {
|
||||
ds, found := b.sources[ref.DS]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("unknown data source %q", ref.DS)
|
||||
}
|
||||
if exists {
|
||||
// Fast path: already subscribed upstream; just add this client.
|
||||
sub.mu.Lock()
|
||||
sub.clients[ch] = struct{}{}
|
||||
sub.mu.Unlock()
|
||||
b.mu.Unlock()
|
||||
return func() { b.unsubscribe(ref, ch) }, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Slow path: need to start an upstream subscription.
|
||||
// Release the lock before the potentially-blocking ds.Subscribe call.
|
||||
ds, found := b.sources[ref.DS]
|
||||
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{
|
||||
clients: make(map[chan<- Update]struct{}),
|
||||
done: make(chan struct{}),
|
||||
@@ -116,6 +140,7 @@ func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
|
||||
sub.mu.Lock()
|
||||
sub.clients[ch] = struct{}{}
|
||||
sub.mu.Unlock()
|
||||
b.mu.Unlock()
|
||||
|
||||
return func() { b.unsubscribe(ref, ch) }, nil
|
||||
}
|
||||
|
||||
+19
-16
@@ -10,8 +10,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
EPICS EPICSConfig `toml:"datasource.epics"`
|
||||
Synthetic SyntheticConfig `toml:"datasource.synthetic"`
|
||||
Datasource DatasourceConfig `toml:"datasource"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -19,6 +18,16 @@ type ServerConfig struct {
|
||||
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 {
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
@@ -27,8 +36,7 @@ type EPICSConfig struct {
|
||||
}
|
||||
|
||||
type SyntheticConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DefinitionsFile string `toml:"definitions_file"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -37,12 +45,10 @@ func Default() Config {
|
||||
Listen: ":8080",
|
||||
StorageDir: "./interfaces",
|
||||
},
|
||||
EPICS: EPICSConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Synthetic: SyntheticConfig{
|
||||
Enabled: true,
|
||||
DefinitionsFile: "./synthetic.json",
|
||||
Datasource: DatasourceConfig{
|
||||
Stub: StubConfig{Enabled: true},
|
||||
EPICS: EPICSConfig{Enabled: true},
|
||||
Synthetic: SyntheticConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -71,16 +77,13 @@ func applyEnv(cfg *Config) {
|
||||
cfg.Server.StorageDir = 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 != "" {
|
||||
cfg.EPICS.ArchiveURL = v
|
||||
cfg.Datasource.EPICS.ArchiveURL = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||
cfg.EPICS.ChannelFinderURL = v
|
||||
}
|
||||
if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" {
|
||||
cfg.Synthetic.DefinitionsFile = v
|
||||
cfg.Datasource.EPICS.ChannelFinderURL = v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package epics
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Wno-unused-function
|
||||
#include "ca_wrapper.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* goCAConnectionCallback is called when a channel connects or disconnects.
|
||||
*/
|
||||
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
|
||||
const void *dbr, int severity,
|
||||
void *dbr, int severity,
|
||||
double epicsTimeSecs);
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
package epics
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Wall
|
||||
#cgo CFLAGS: -Wall -Wno-unused-function
|
||||
#include "ca_wrapper.h"
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
@@ -26,6 +26,7 @@ import "C"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -68,6 +69,12 @@ type EPICS struct {
|
||||
caAddrList 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
|
||||
channels map[string]*caChannel // PV name → CA channel
|
||||
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
|
||||
// ca_pend_event polling loop.
|
||||
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 != "" {
|
||||
cs := C.CString(e.caAddrList)
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
// 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)
|
||||
os.Setenv("EPICS_CA_ADDR_LIST", e.caAddrList)
|
||||
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
|
||||
}
|
||||
|
||||
status := C.ca_context_create(C.ca_enable_preemptive_callback)
|
||||
if status != C.ECA_NORMAL {
|
||||
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
|
||||
}
|
||||
|
||||
// Subscribe connects to the named PV (if not already connected), installs a
|
||||
// monitor, and streams value updates into ch. The returned CancelFunc removes
|
||||
// the monitor and clears the channel.
|
||||
// attachCAContext attaches the saved CA context to the calling OS thread.
|
||||
// Must be called at the top of every goroutine that uses CA functions,
|
||||
// because Go goroutines can be scheduled onto any OS thread.
|
||||
// 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) {
|
||||
// Attach the CA context to this OS thread before any CA calls.
|
||||
e.attachCAContext()
|
||||
|
||||
handle := nextHandle()
|
||||
|
||||
// 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
|
||||
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)
|
||||
defer C.free(unsafe.Pointer(pvName))
|
||||
|
||||
@@ -142,54 +166,78 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
|
||||
handleMu.Unlock()
|
||||
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()
|
||||
|
||||
select {
|
||||
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}
|
||||
|
||||
// Track the channel immediately so Write can find the chid.
|
||||
e.mu.Lock()
|
||||
e.channels[signal] = caCh
|
||||
e.channels[signal] = &caChannel{chid: chid, handle: handle}
|
||||
e.mu.Unlock()
|
||||
|
||||
cancel := datasource.CancelFunc(func() {
|
||||
if evid != nil {
|
||||
// Use a done channel to signal the goroutine to stop.
|
||||
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_channel(chid)
|
||||
@@ -203,15 +251,18 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
|
||||
e.mu.Lock()
|
||||
delete(e.channels, signal)
|
||||
e.mu.Unlock()
|
||||
})
|
||||
}()
|
||||
|
||||
return cancel, nil
|
||||
return datasource.CancelFunc(stopFn), nil
|
||||
}
|
||||
|
||||
// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
|
||||
// appropriate control type) to retrieve units, display limits, enum strings,
|
||||
// and writability information for the named signal.
|
||||
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.
|
||||
e.mu.Lock()
|
||||
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.
|
||||
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)
|
||||
count := C.ca_element_count(chid)
|
||||
@@ -326,7 +380,7 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||
switch ctrlType {
|
||||
case C.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 {
|
||||
C.ca_pend_io(3.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.DriveLow = float64(buf.lower_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:
|
||||
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 {
|
||||
C.ca_pend_io(3.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.DriveLow = float64(buf.lower_ctrl_limit)
|
||||
meta.DriveHigh = float64(buf.upper_ctrl_limit)
|
||||
meta.Writable = true
|
||||
}
|
||||
|
||||
case C.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 {
|
||||
C.ca_pend_io(3.0)
|
||||
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])))
|
||||
}
|
||||
meta.EnumStrings = strs
|
||||
meta.Writable = true
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// ListSignals returns cached metadata for all currently connected channels.
|
||||
// Full enumeration of all available PVs is deferred to Phase 9.
|
||||
// ListSignals returns metadata for all currently tracked channels.
|
||||
// Channels with cached metadata return full info; others return a stub entry
|
||||
// with the PV name so the signal tree can display them immediately after
|
||||
// a manual add, before GetMetadata has been called.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||
for _, m := range e.metadata {
|
||||
// Start with all fully-fetched metadata entries.
|
||||
out := make([]datasource.Metadata, 0, len(e.channels))
|
||||
seen := make(map[string]bool, len(e.metadata))
|
||||
for name, m := range e.metadata {
|
||||
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
|
||||
}
|
||||
|
||||
// Write puts a new value onto a CA channel.
|
||||
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
|
||||
e.attachCAContext()
|
||||
e.mu.Lock()
|
||||
caCh, ok := e.channels[signal]
|
||||
e.mu.Unlock()
|
||||
@@ -396,20 +457,20 @@ func (e *EPICS) Write(_ context.Context, signal string, value any) error {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
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:
|
||||
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:
|
||||
cs := C.CString(v)
|
||||
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:
|
||||
var iv C.short
|
||||
if v {
|
||||
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:
|
||||
return fmt.Errorf("epics: unsupported value type %T for Write", value)
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ const (
|
||||
|
||||
// Value is a timestamped signal reading.
|
||||
type Value struct {
|
||||
Timestamp time.Time
|
||||
Data any // float64 | []float64 | string | int64 | bool
|
||||
Quality Quality
|
||||
Timestamp time.Time
|
||||
Data any // float64 | []float64 | string | int64 | bool
|
||||
Quality Quality
|
||||
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
|
||||
}
|
||||
|
||||
// Metadata describes the static properties of a signal.
|
||||
|
||||
@@ -163,6 +163,12 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
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{
|
||||
Type: "update",
|
||||
DS: u.Ref.DS,
|
||||
@@ -265,6 +271,7 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
|
||||
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -272,10 +279,12 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
// Enforce write permission before touching the value payload.
|
||||
meta, err := ds.GetMetadata(ctx, msg.Name)
|
||||
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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -283,12 +292,15 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
// Decode the JSON value as the appropriate Go type.
|
||||
var value any
|
||||
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())
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
|
||||
metrics.IncWrites()
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -3,10 +3,14 @@ import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
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() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
@@ -20,9 +24,13 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
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) {
|
||||
setEditTarget(iface ?? null);
|
||||
setMode('edit');
|
||||
|
||||
+101
-15
@@ -37,6 +37,11 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
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 {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
@@ -46,6 +51,14 @@ interface PickerState {
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
interface SignalDropMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
signal: SignalRef;
|
||||
targetWidget: Widget;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
@@ -65,9 +78,9 @@ interface ResizeState {
|
||||
|
||||
interface RubberBand {
|
||||
active: boolean;
|
||||
startX: number; startY: number; // canvas-relative
|
||||
startX: number; startY: number;
|
||||
curX: number; curY: number;
|
||||
clientStartX: number; clientStartY: number; // for scroll adjustment
|
||||
clientStartX: number; clientStartY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -75,7 +88,7 @@ interface Props {
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
snapGrid: number; // 0 = off
|
||||
snapGrid: number;
|
||||
}
|
||||
|
||||
let _widgetSeq = Date.now();
|
||||
@@ -95,7 +108,6 @@ function rectsIntersect(
|
||||
export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs: always hold current values so event handlers don't go stale
|
||||
const ifaceRef = useRef(iface);
|
||||
ifaceRef.current = iface;
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -111,14 +123,14 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
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 [picker, setPicker] = useState<PickerState>({
|
||||
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(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const grid = snapGridRef.current;
|
||||
@@ -213,7 +225,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
function handleCanvasMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest('.widget-overlay')) 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);
|
||||
rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY };
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
@@ -232,10 +244,50 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!picker.signal) return;
|
||||
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 }));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
e.stopPropagation();
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
@@ -274,7 +322,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
}
|
||||
onSelect(nextIds);
|
||||
|
||||
// Don't start drag on Ctrl+click deselect
|
||||
if (ctrl && isSelected) return;
|
||||
|
||||
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;`}>
|
||||
|
||||
{/* Live widget previews */}
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
@@ -330,7 +376,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Interaction overlays */}
|
||||
{iface.widgets.map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
return (
|
||||
@@ -361,7 +406,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rubber-band selection rectangle */}
|
||||
{rubberVis && (
|
||||
<div
|
||||
class="rubber-band"
|
||||
@@ -378,6 +422,48 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => 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 [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -60,6 +61,10 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
function handleFullscreen(id: string) {
|
||||
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<div class="panel-header">
|
||||
@@ -100,7 +105,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<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 iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
|
||||
+175
-53
@@ -4,7 +4,7 @@ import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
selected: Widget | null;
|
||||
multiCount: number; // >0 when multiple widgets are selected
|
||||
multiCount: number;
|
||||
iface: Interface;
|
||||
onChange: (updated: Widget) => 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 }) {
|
||||
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 (
|
||||
<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) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
@@ -44,6 +55,14 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
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 (
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||
<div class="panel-header">
|
||||
@@ -55,7 +74,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
|
||||
{!collapsed && (
|
||||
<div class="props-body">
|
||||
{/* Interface-level properties (always shown) */}
|
||||
{/* Canvas-level properties */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<Field label="Name">
|
||||
@@ -84,117 +103,220 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
{w && (
|
||||
<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">
|
||||
<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)} />
|
||||
</Field>
|
||||
<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)} />
|
||||
</Field>
|
||||
<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)} />
|
||||
</Field>
|
||||
<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)} />
|
||||
</Field>
|
||||
|
||||
{/* Signal */}
|
||||
{selected.signals.length > 0 && (
|
||||
{/* Signals */}
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Common options */}
|
||||
<Field label="Label">
|
||||
<TextInput
|
||||
value={selected.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
</Field>
|
||||
{/* Unit override for signal widgets */}
|
||||
{UNIT_WIDGETS.has(w.type) && (
|
||||
<Field label="Unit override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Type-specific options */}
|
||||
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
|
||||
{/* ── Type-specific options ──────────────────────────────────── */}
|
||||
|
||||
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'led' && (
|
||||
{w.type === 'led' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{selected.type === 'setvalue' && (
|
||||
<Field label="Min">
|
||||
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
{w.type === 'setvalue' && (
|
||||
<div>
|
||||
<Field label="Min">
|
||||
<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' && (
|
||||
<Field label="Plot type">
|
||||
<select
|
||||
class="prop-select"
|
||||
value={selected.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>
|
||||
{/* Button options (issue #2) */}
|
||||
{w.type === 'button' && (
|
||||
<div>
|
||||
<Field label="Send value">
|
||||
<TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
|
||||
</Field>
|
||||
<Field label="Mode">
|
||||
<select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
|
||||
onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="oneshot">One-shot (set once)</option>
|
||||
<option value="setReset">Set-reset (auto-revert)</option>
|
||||
<option value="persistent">Persistent (toggle / monitor)</option>
|
||||
</select>
|
||||
</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>
|
||||
<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 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'image' && (
|
||||
{w.type === 'image' && (
|
||||
<Field label="URL">
|
||||
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||
<TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'link' && (
|
||||
{w.type === 'link' && (
|
||||
<div>
|
||||
<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 label="Label">
|
||||
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
</Field>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!selected && multiCount === 0 && (
|
||||
{!w && multiCount === 0 && (
|
||||
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+58
-36
@@ -1,11 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import { useState, useMemo, useEffect } from 'preact/hooks';
|
||||
import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface } from './lib/types';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
|
||||
@@ -25,6 +24,7 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
|
||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||
|
||||
@@ -52,13 +52,24 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const xml = await res.text();
|
||||
handleLoad(xml);
|
||||
handleLoad(await res.text());
|
||||
} catch (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() {
|
||||
if (!startInput || !endInput) return;
|
||||
const start = new Date(startInput).toISOString();
|
||||
@@ -82,38 +93,6 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
<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>
|
||||
{wsStatus}
|
||||
</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} />
|
||||
<button
|
||||
class="icon-btn help-manual-btn"
|
||||
@@ -141,10 +127,46 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
</div>
|
||||
</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">
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onEditId={handleEditById}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -259,6 +259,7 @@ class WsClient {
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
+134
-27
@@ -994,33 +994,7 @@ body {
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* ── Time range / history navigation ──────────────────────────────────────── */
|
||||
|
||||
.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;
|
||||
}
|
||||
/* time-nav styles are in the time-nav-bar section at the bottom of this file */
|
||||
|
||||
/* Overlay shown inside a plot widget while history is loading or unavailable */
|
||||
.hist-overlay {
|
||||
@@ -1966,3 +1940,136 @@ kbd {
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
+50
-10
@@ -1,24 +1,59 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Button({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const valueOpt = widget.options['value'] ?? '1';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const sendValue = widget.options['value'] ?? '1';
|
||||
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() {
|
||||
if (!sigRef) return;
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
if (confirmOpt) {
|
||||
if (window.confirm(`Send ${label}?`)) execute();
|
||||
} 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;`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,9 @@ export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
|
||||
|
||||
@@ -64,15 +64,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
||||
|
||||
// Re-render chart when switching between live and historical mode.
|
||||
// We stringify timeRange so the effect dependency is a stable primitive.
|
||||
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
||||
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(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const signals = widget.signals;
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
||||
const yMin = widget.options['yMin'];
|
||||
const yMax = widget.options['yMax'];
|
||||
@@ -329,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id, timeRangeKey]);
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -30,10 +30,11 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const isNumeric = meta ? meta.type !== 'TypeString' : true;
|
||||
const isNumeric = meta ? meta.type !== 'string' : true;
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function TextView({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
|
||||
@@ -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 & 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 (0–3)"/>
|
||||
<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 0–1) -->
|
||||
<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>
|
||||
Executable
+124
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
Starting iocInit
|
||||
iocRun: All initialization complete
|
||||
Executable
+81
@@ -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"
|
||||
@@ -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 10–90 °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 0–10 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 20–80 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 0–1, 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")
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user