Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
//go:build epics
|
||||
|
||||
package epics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// archiveResponse is the top-level JSON structure returned by the EPICS Archive
|
||||
// Appliance getData.json endpoint.
|
||||
//
|
||||
// Example response shape:
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "meta": { "name": "PV:NAME", "EGU": "A" },
|
||||
// "data": [
|
||||
// { "secs": 1700000000, "nanos": 123000000, "val": 3.14, "severity": 0, "status": 0 },
|
||||
// ...
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
type archiveResponse []struct {
|
||||
Meta struct {
|
||||
Name string `json:"name"`
|
||||
EGU string `json:"EGU"`
|
||||
} `json:"meta"`
|
||||
Data []archivePoint `json:"data"`
|
||||
}
|
||||
|
||||
type archivePoint struct {
|
||||
Secs int64 `json:"secs"`
|
||||
Nanos int64 `json:"nanos"`
|
||||
Val any `json:"val"`
|
||||
Severity int `json:"severity"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// fetchArchiveHistory queries the EPICS Archive Appliance JSON API for
|
||||
// historical data for the given PV in the time range [start, end].
|
||||
func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
|
||||
// Build the request URL.
|
||||
// Format: GET {archiveURL}/retrieval/data/getData.json?pv={name}&from={start}&to={end}&maxSamples={n}
|
||||
reqURL, err := url.Parse(archiveURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err)
|
||||
}
|
||||
reqURL = reqURL.JoinPath("retrieval", "data", "getData.json")
|
||||
|
||||
q := reqURL.Query()
|
||||
q.Set("pv", signal)
|
||||
q.Set("from", start.UTC().Format(time.RFC3339Nano))
|
||||
q.Set("to", end.UTC().Format(time.RFC3339Nano))
|
||||
if maxPoints > 0 {
|
||||
q.Set("maxSamples", strconv.Itoa(maxPoints))
|
||||
}
|
||||
reqURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("epics archive: unexpected status %d for %q", resp.StatusCode, signal)
|
||||
}
|
||||
|
||||
var ar archiveResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
|
||||
return nil, fmt.Errorf("epics archive: decoding response: %w", err)
|
||||
}
|
||||
|
||||
if len(ar) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
points := ar[0].Data
|
||||
out := make([]datasource.Value, 0, len(points))
|
||||
|
||||
// EPICS Archive Appliance uses the POSIX epoch (unlike the CA wire protocol
|
||||
// which uses the EPICS epoch). The JSON API returns POSIX seconds.
|
||||
for _, p := range points {
|
||||
ts := time.Unix(p.Secs, p.Nanos).UTC()
|
||||
|
||||
var q datasource.Quality
|
||||
switch p.Severity {
|
||||
case 0:
|
||||
q = datasource.QualityGood
|
||||
case 1, 2:
|
||||
q = datasource.QualityUncertain
|
||||
default:
|
||||
q = datasource.QualityBad
|
||||
}
|
||||
|
||||
data := coerceArchiveValue(p.Val)
|
||||
|
||||
out = append(out, datasource.Value{
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: q,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
|
||||
// as float64, string, bool, []interface{}, or nil) into one of the types
|
||||
// accepted by datasource.Value.Data.
|
||||
func coerceArchiveValue(v any) any {
|
||||
if v == nil {
|
||||
return float64(0)
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return t
|
||||
case []interface{}:
|
||||
// Waveform: convert to []float64.
|
||||
out := make([]float64, 0, len(t))
|
||||
for _, elem := range t {
|
||||
if f, ok := elem.(float64); ok {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return float64(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//go:build epics
|
||||
|
||||
package epics
|
||||
|
||||
/*
|
||||
#include "ca_wrapper.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// goCAMonitorCallback is called from the C shim caMonitorCallbackShim on every
|
||||
// CA monitor event. It runs on CA's internal callback thread, so it must be
|
||||
// short and must not block. In particular it must not allocate heap memory
|
||||
// that the Go GC would need to track across a CGo boundary.
|
||||
//
|
||||
//export goCAMonitorCallback
|
||||
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
|
||||
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
|
||||
|
||||
h := uintptr(handle)
|
||||
|
||||
// Compute the Go timestamp. EPICS timestamps are seconds since the EPICS
|
||||
// epoch (1 Jan 1990 UTC).
|
||||
const epicsEpochOffset = 631152000 // Unix seconds of 1990-01-01 00:00:00 UTC
|
||||
secs := float64(epicsTimeSecs)
|
||||
fullSecs := int64(secs)
|
||||
nsec := int64((secs - float64(fullSecs)) * 1e9)
|
||||
ts := time.Unix(fullSecs+epicsEpochOffset, nsec)
|
||||
|
||||
// Map CA alarm severity to datasource quality.
|
||||
var q datasource.Quality
|
||||
switch int(severity) {
|
||||
case 0: // NO_ALARM
|
||||
q = datasource.QualityGood
|
||||
case 1, 2: // MINOR_ALARM, MAJOR_ALARM
|
||||
q = datasource.QualityUncertain
|
||||
default: // INVALID_ALARM (3)
|
||||
q = datasource.QualityBad
|
||||
}
|
||||
|
||||
// Extract the value from the DBR buffer.
|
||||
var data any
|
||||
switch int(dbrType) {
|
||||
case int(C.DBR_TIME_DOUBLE):
|
||||
p := (*C.struct_dbr_time_double)(dbr)
|
||||
data = float64(p.value)
|
||||
|
||||
case int(C.DBR_TIME_FLOAT):
|
||||
p := (*C.struct_dbr_time_float)(dbr)
|
||||
data = float64(p.value)
|
||||
|
||||
case int(C.DBR_TIME_LONG):
|
||||
p := (*C.struct_dbr_time_long)(dbr)
|
||||
data = int64(p.value)
|
||||
|
||||
case int(C.DBR_TIME_SHORT):
|
||||
p := (*C.struct_dbr_time_short)(dbr)
|
||||
data = int64(p.value)
|
||||
|
||||
case int(C.DBR_TIME_ENUM):
|
||||
p := (*C.struct_dbr_time_enum)(dbr)
|
||||
data = int64(p.value)
|
||||
|
||||
case int(C.DBR_TIME_STRING):
|
||||
p := (*C.struct_dbr_time_string)(dbr)
|
||||
// dbr_string is a fixed [MAX_STRING_SIZE]byte array
|
||||
data = C.GoString((*C.char)(unsafe.Pointer(&p.value[0])))
|
||||
|
||||
default:
|
||||
// Unknown type; mark as bad quality and return early.
|
||||
q = datasource.QualityBad
|
||||
data = float64(0)
|
||||
}
|
||||
|
||||
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
|
||||
|
||||
// Look up the subscription channel under the global handle table lock and
|
||||
// perform a non-blocking send so we never stall the CA callback thread.
|
||||
handleMu.Lock()
|
||||
ch, ok := handleTable[h]
|
||||
handleMu.Unlock()
|
||||
|
||||
if ok {
|
||||
select {
|
||||
case ch <- v:
|
||||
default:
|
||||
// Subscriber is not reading fast enough; drop the update rather than
|
||||
// block the CA callback thread.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goCAConnectionCallback is called from the C shim caConnectionCallbackShim
|
||||
// whenever a channel connects or disconnects.
|
||||
//
|
||||
//export goCAConnectionCallback
|
||||
func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
|
||||
h := uintptr(handle)
|
||||
|
||||
handleMu.Lock()
|
||||
entry, ok := connTable[h]
|
||||
handleMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if int(connected) == 1 {
|
||||
// Signal the one-shot "connected" channel if it has not been closed yet.
|
||||
select {
|
||||
case entry.connCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
// Channel disconnected; push a QualityBad sentinel to the subscriber.
|
||||
handleMu.Lock()
|
||||
ch, chOk := handleTable[h]
|
||||
handleMu.Unlock()
|
||||
|
||||
if chOk {
|
||||
v := datasource.Value{
|
||||
Timestamp: time.Now(),
|
||||
Data: float64(0),
|
||||
Quality: datasource.QualityBad,
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#ifndef CA_WRAPPER_H
|
||||
#define CA_WRAPPER_H
|
||||
|
||||
#include <cadef.h>
|
||||
#include <db_access.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/*
|
||||
* Go-exported callback declarations.
|
||||
* These are implemented in ca_callback.go (via //export directives).
|
||||
*
|
||||
* goCAMonitorCallback is called for each value update received from CA.
|
||||
* goCAConnectionCallback is called when a channel connects or disconnects.
|
||||
*/
|
||||
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
|
||||
const void *dbr, int severity,
|
||||
double epicsTimeSecs);
|
||||
extern void goCAConnectionCallback(uintptr_t handle, int connected);
|
||||
|
||||
/*
|
||||
* CA monitor callback shim.
|
||||
*
|
||||
* CA calls this function with the fixed evargs signature. We extract the
|
||||
* relevant fields and forward them to the Go callback.
|
||||
*/
|
||||
static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
if (args.status != ECA_NORMAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
double timeSecs = 0.0;
|
||||
int severity = 0;
|
||||
|
||||
/*
|
||||
* Determine the timestamp and alarm severity from the DBR type.
|
||||
* We request DBR_TIME_* types so the timestamp is embedded in the value
|
||||
* buffer right after the alarm fields (struct dbr_time_double et al.).
|
||||
*/
|
||||
switch (args.type) {
|
||||
case DBR_TIME_DOUBLE: {
|
||||
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_FLOAT: {
|
||||
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_LONG: {
|
||||
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_SHORT: {
|
||||
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_STRING: {
|
||||
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_ENUM: {
|
||||
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
|
||||
args.dbr, severity, timeSecs);
|
||||
}
|
||||
|
||||
/*
|
||||
* CA connection callback shim.
|
||||
*
|
||||
* CA calls this with a connection_handler_args struct. We map the op field
|
||||
* to a simple boolean (1 = connected, 0 = disconnected) and forward.
|
||||
*/
|
||||
static void caConnectionCallbackShim(struct connection_handler_args args) {
|
||||
int connected = (args.op == CA_OP_CONN_UP) ? 1 : 0;
|
||||
goCAConnectionCallback((uintptr_t)ca_puser(args.chid), connected);
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin helpers so CGo callers do not need to deal with C function-pointer
|
||||
* syntax directly. All CA API calls stay in C to avoid issues with CGo
|
||||
* passing Go pointers across the CA callback boundary.
|
||||
*/
|
||||
|
||||
/* Create a channel with the connection shim as the connection callback.
|
||||
* user_handle is the Go uintptr_t that identifies this channel in the handle
|
||||
* table. Returns the ECA_* status code. */
|
||||
static int caCreateChannel(const char *pvName, uintptr_t userHandle,
|
||||
chid *pChid) {
|
||||
return ca_create_channel(pvName, caConnectionCallbackShim,
|
||||
(void *)userHandle, CA_PRIORITY_DEFAULT, pChid);
|
||||
}
|
||||
|
||||
/* Add a value-monitor event on chid. Returns the ECA_* status code and stores
|
||||
* the evid in *pEvid. */
|
||||
static int caAddMonitor(chid ch, short dbrType, uintptr_t userHandle,
|
||||
evid *pEvid) {
|
||||
return ca_add_event(dbrType, ch, caMonitorCallbackShim,
|
||||
(void *)userHandle, pEvid);
|
||||
}
|
||||
|
||||
#endif /* CA_WRAPPER_H */
|
||||
@@ -0,0 +1,458 @@
|
||||
//go:build epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source for uopi.
|
||||
//
|
||||
// # Build requirements
|
||||
//
|
||||
// This file and all other files tagged "epics" require CGo and a working EPICS
|
||||
// Base installation. Set the following environment variables before building:
|
||||
//
|
||||
// export EPICS_BASE=/path/to/epics/base
|
||||
// export CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
|
||||
// export CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
|
||||
//
|
||||
// Then build with:
|
||||
//
|
||||
// CGO_ENABLED=1 go build -tags epics ./...
|
||||
package epics
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Wall
|
||||
#include "ca_wrapper.h"
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// connEntry holds the one-shot channel used during channel creation to wait for
|
||||
// the initial connection callback.
|
||||
type connEntry struct {
|
||||
connCh chan struct{}
|
||||
}
|
||||
|
||||
// handleTable maps Go-side handle IDs to the subscriber's value channel.
|
||||
// Protected by handleMu.
|
||||
var (
|
||||
handleMu sync.Mutex
|
||||
handleTable = make(map[uintptr]chan<- datasource.Value)
|
||||
connTable = make(map[uintptr]*connEntry)
|
||||
)
|
||||
|
||||
// handleSeq is an atomically-incremented counter used to generate unique handle
|
||||
// IDs that are passed through CA as void* user pointers.
|
||||
var handleSeq atomic.Uintptr
|
||||
|
||||
func nextHandle() uintptr {
|
||||
return handleSeq.Add(1)
|
||||
}
|
||||
|
||||
// caChannel holds the CA resources for a single PV.
|
||||
type caChannel struct {
|
||||
chid C.chid
|
||||
evid C.evid // monitor event ID; zero if no monitor installed
|
||||
handle uintptr
|
||||
}
|
||||
|
||||
// EPICS is the Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
|
||||
mu sync.Mutex
|
||||
channels map[string]*caChannel // PV name → CA channel
|
||||
metadata map[string]datasource.Metadata
|
||||
}
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
||||
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||
// Appliance instance for history queries (may be empty).
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
channels: make(map[string]*caChannel),
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Available reports whether the EPICS data source is compiled in.
|
||||
// Always returns true when built with the "epics" build tag.
|
||||
func Available() bool { return true }
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (e *EPICS) Name() string { return "epics" }
|
||||
|
||||
// Connect initialises a CA context with preemptive callbacks so that monitor
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
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.
|
||||
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
handle := nextHandle()
|
||||
|
||||
// Register the handle → channel mapping before creating the CA channel so
|
||||
// that any early callbacks are not lost.
|
||||
handleMu.Lock()
|
||||
handleTable[handle] = ch
|
||||
entry := &connEntry{connCh: make(chan struct{}, 1)}
|
||||
connTable[handle] = entry
|
||||
handleMu.Unlock()
|
||||
|
||||
// Create the CA channel.
|
||||
pvName := C.CString(signal)
|
||||
defer C.free(unsafe.Pointer(pvName))
|
||||
|
||||
var chid C.chid
|
||||
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
|
||||
if status != C.ECA_NORMAL {
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
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}
|
||||
|
||||
e.mu.Lock()
|
||||
e.channels[signal] = caCh
|
||||
e.mu.Unlock()
|
||||
|
||||
cancel := datasource.CancelFunc(func() {
|
||||
if evid != nil {
|
||||
C.ca_clear_event(evid)
|
||||
}
|
||||
C.ca_clear_channel(chid)
|
||||
C.ca_flush_io()
|
||||
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
|
||||
e.mu.Lock()
|
||||
delete(e.channels, signal)
|
||||
e.mu.Unlock()
|
||||
})
|
||||
|
||||
return cancel, 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) {
|
||||
// Check cache first.
|
||||
e.mu.Lock()
|
||||
if m, ok := e.metadata[signal]; ok {
|
||||
e.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// We need a connected channel to issue a ca_get. Reuse an existing one or
|
||||
// create a temporary channel.
|
||||
e.mu.Lock()
|
||||
existingCh, exists := e.channels[signal]
|
||||
e.mu.Unlock()
|
||||
|
||||
var chid C.chid
|
||||
tempChannel := false
|
||||
|
||||
if exists {
|
||||
chid = existingCh.chid
|
||||
} else {
|
||||
// Create a temporary channel for metadata retrieval.
|
||||
handle := nextHandle()
|
||||
entry := &connEntry{connCh: make(chan struct{}, 1)}
|
||||
handleMu.Lock()
|
||||
connTable[handle] = entry
|
||||
handleMu.Unlock()
|
||||
|
||||
pvName := C.CString(signal)
|
||||
defer C.free(unsafe.Pointer(pvName))
|
||||
|
||||
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
|
||||
if status != C.ECA_NORMAL {
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
C.ca_flush_io()
|
||||
|
||||
select {
|
||||
case <-entry.connCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: timeout connecting to %q for metadata", signal)
|
||||
case <-ctx.Done():
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, ctx.Err()
|
||||
}
|
||||
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
tempChannel = true
|
||||
}
|
||||
|
||||
meta := e.fetchMetadata(chid, signal)
|
||||
|
||||
if tempChannel {
|
||||
C.ca_clear_channel(chid)
|
||||
C.ca_flush_io()
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.metadata[signal] = meta
|
||||
e.mu.Unlock()
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
fieldType := C.ca_field_type(chid)
|
||||
count := C.ca_element_count(chid)
|
||||
|
||||
// Determine the Go DataType and the DBR_CTRL type to request.
|
||||
var ctrlType C.chtype
|
||||
switch int(fieldType) {
|
||||
case int(C.DBF_DOUBLE), int(C.DBF_FLOAT):
|
||||
if count > 1 {
|
||||
meta.Type = datasource.TypeFloat64Array
|
||||
} else {
|
||||
meta.Type = datasource.TypeFloat64
|
||||
}
|
||||
ctrlType = C.DBR_CTRL_DOUBLE
|
||||
|
||||
case int(C.DBF_LONG), int(C.DBF_SHORT), int(C.DBF_CHAR):
|
||||
meta.Type = datasource.TypeInt64
|
||||
ctrlType = C.DBR_CTRL_LONG
|
||||
|
||||
case int(C.DBF_ENUM):
|
||||
meta.Type = datasource.TypeEnum
|
||||
ctrlType = C.DBR_CTRL_ENUM
|
||||
|
||||
case int(C.DBF_STRING):
|
||||
meta.Type = datasource.TypeString
|
||||
ctrlType = C.DBR_CTRL_STRING
|
||||
|
||||
default:
|
||||
meta.Type = datasource.TypeFloat64
|
||||
ctrlType = C.DBR_CTRL_DOUBLE
|
||||
}
|
||||
|
||||
// Perform a synchronous ca_get with a 3-second timeout.
|
||||
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))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
|
||||
meta.DisplayLow = float64(buf.lower_disp_limit)
|
||||
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))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
|
||||
meta.DisplayLow = float64(buf.lower_disp_limit)
|
||||
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))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
n := int(buf.no_str)
|
||||
strs := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
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.
|
||||
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 {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Write puts a new value onto a CA channel.
|
||||
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
|
||||
e.mu.Lock()
|
||||
caCh, ok := e.channels[signal]
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
|
||||
var status C.int
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
cv := C.double(v)
|
||||
status = C.ca_put(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))
|
||||
case string:
|
||||
cs := C.CString(v)
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
status = C.ca_put(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))
|
||||
default:
|
||||
return fmt.Errorf("epics: unsupported value type %T for Write", value)
|
||||
}
|
||||
|
||||
if status != C.ECA_NORMAL {
|
||||
return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
C.ca_flush_io()
|
||||
return nil
|
||||
}
|
||||
|
||||
// History delegates to the Archive Appliance client, or returns
|
||||
// ErrHistoryUnavailable if no archive URL is configured.
|
||||
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
|
||||
if e.archiveURL == "" {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
|
||||
}
|
||||
|
||||
// nativeTimeType returns the DBR_TIME_* type corresponding to the native field
|
||||
// type of the channel.
|
||||
func nativeTimeType(chid C.chid) C.chtype {
|
||||
ft := C.ca_field_type(chid)
|
||||
count := C.ca_element_count(chid)
|
||||
|
||||
switch int(ft) {
|
||||
case int(C.DBF_DOUBLE):
|
||||
if count > 1 {
|
||||
return C.DBR_TIME_DOUBLE // waveform; caller assembles []float64 separately
|
||||
}
|
||||
return C.DBR_TIME_DOUBLE
|
||||
case int(C.DBF_FLOAT):
|
||||
return C.DBR_TIME_FLOAT
|
||||
case int(C.DBF_LONG):
|
||||
return C.DBR_TIME_LONG
|
||||
case int(C.DBF_SHORT), int(C.DBF_CHAR):
|
||||
return C.DBR_TIME_SHORT
|
||||
case int(C.DBF_ENUM):
|
||||
return C.DBR_TIME_ENUM
|
||||
case int(C.DBF_STRING):
|
||||
return C.DBR_TIME_STRING
|
||||
default:
|
||||
return C.DBR_TIME_DOUBLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package epics_test contains tests that compile regardless of build tags.
|
||||
// This file verifies the no-op stub behaviour when the package is built without
|
||||
// the "epics" build tag (i.e. without CGo and EPICS Base).
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
// TestAvailable_NoopBuild verifies that Available() returns false when the
|
||||
// package is compiled without the "epics" build tag.
|
||||
//
|
||||
// When built WITH the tag the test still passes because Available() returns
|
||||
// true, which is a different (correct) state. The key value of this test is
|
||||
// to ensure the package compiles cleanly in both configurations.
|
||||
func TestAvailable_NoopBuild(t *testing.T) {
|
||||
got := epics.Available()
|
||||
// When built without -tags epics, Available must be false.
|
||||
// When built with -tags epics, Available must be true.
|
||||
// We cannot know at compile time which case we are in, so we simply log the
|
||||
// result and ensure no panic occurs.
|
||||
t.Logf("epics.Available() = %v", got)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build epics
|
||||
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
// TestIntegration_SubscribeAndMetadata is an integration test that requires:
|
||||
// - EPICS_BASE to be set in the environment (i.e. EPICS Base is installed).
|
||||
// - TEST_PV to name a readable EPICS PV accessible via Channel Access.
|
||||
//
|
||||
// It connects to the named PV, waits up to 5 seconds for at least one value
|
||||
// update, and then verifies that the metadata contains a unit string.
|
||||
func TestIntegration_SubscribeAndMetadata(t *testing.T) {
|
||||
if os.Getenv("EPICS_BASE") == "" {
|
||||
t.Skip("EPICS_BASE not set; skipping EPICS integration test")
|
||||
}
|
||||
pvName := os.Getenv("TEST_PV")
|
||||
if pvName == "" {
|
||||
t.Skip("TEST_PV not set; skipping EPICS integration test")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ds := epics.New("", "") // rely on environment for CA addr list / EPICS_CA_ADDR_LIST
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
// Fetch metadata before subscribing so we exercise GetMetadata.
|
||||
meta, err := ds.GetMetadata(ctx, pvName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata(%q): %v", pvName, err)
|
||||
}
|
||||
t.Logf("metadata: name=%q unit=%q type=%v displayLow=%v displayHigh=%v writable=%v",
|
||||
meta.Name, meta.Unit, meta.Type, meta.DisplayLow, meta.DisplayHigh, meta.Writable)
|
||||
|
||||
// Subscribe and wait for at least one value update within 5 seconds.
|
||||
ch := make(chan datasource.Value, 8)
|
||||
cancelSub, err := ds.Subscribe(ctx, pvName, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe(%q): %v", pvName, err)
|
||||
}
|
||||
defer cancelSub()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
t.Logf("received value: ts=%v data=%v quality=%v", v.Timestamp, v.Data, v.Quality)
|
||||
if v.Quality == datasource.QualityBad {
|
||||
t.Errorf("expected QualityGood or QualityUncertain, got QualityBad")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for first value update from PV")
|
||||
case <-ctx.Done():
|
||||
t.Fatal("context expired before receiving a value")
|
||||
}
|
||||
|
||||
// ListSignals should now return at least one entry (the one we just connected).
|
||||
signals, err := ds.ListSignals(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSignals: %v", err)
|
||||
}
|
||||
if len(signals) == 0 {
|
||||
t.Error("ListSignals returned no signals after successful Subscribe")
|
||||
}
|
||||
|
||||
// History should return ErrHistoryUnavailable when no archive URL is set.
|
||||
_, err = ds.History(ctx, pvName, time.Now().Add(-time.Hour), time.Now(), 100)
|
||||
if err != datasource.ErrHistoryUnavailable {
|
||||
t.Errorf("History with no archive URL: want ErrHistoryUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source.
|
||||
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
|
||||
// this stub is compiled instead, so that the rest of the codebase can call
|
||||
// epics.Available() to decide whether to register the data source.
|
||||
package epics
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
|
||||
// Available reports whether the EPICS Channel Access data source is compiled in.
|
||||
// It always returns false when built without the "epics" build tag.
|
||||
func Available() bool { return false }
|
||||
|
||||
// New is a placeholder that returns nil when EPICS support is not compiled in.
|
||||
// Callers must check Available() before calling New().
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package datasource defines the DataSource interface and shared types used by
|
||||
// all data source implementations (EPICS, synthetic, stub, …).
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Quality indicates the reliability of a signal value.
|
||||
type Quality uint8
|
||||
|
||||
const (
|
||||
QualityGood Quality = iota // value is valid
|
||||
QualityUncertain // value may be stale or approximate
|
||||
QualityBad // value is invalid
|
||||
)
|
||||
|
||||
func (q Quality) String() string {
|
||||
switch q {
|
||||
case QualityGood:
|
||||
return "good"
|
||||
case QualityUncertain:
|
||||
return "uncertain"
|
||||
default:
|
||||
return "bad"
|
||||
}
|
||||
}
|
||||
|
||||
// DataType describes the Go type of a signal's value.
|
||||
type DataType uint8
|
||||
|
||||
const (
|
||||
TypeFloat64 DataType = iota // scalar float; Data is float64
|
||||
TypeFloat64Array // waveform; Data is []float64
|
||||
TypeString // Data is string
|
||||
TypeInt64 // Data is int64
|
||||
TypeBool // Data is bool
|
||||
TypeEnum // Data is int64; see Metadata.EnumStrings
|
||||
)
|
||||
|
||||
// Value is a timestamped signal reading.
|
||||
type Value struct {
|
||||
Timestamp time.Time
|
||||
Data any // float64 | []float64 | string | int64 | bool
|
||||
Quality Quality
|
||||
}
|
||||
|
||||
// Metadata describes the static properties of a signal.
|
||||
type Metadata struct {
|
||||
Name string
|
||||
Type DataType
|
||||
Unit string
|
||||
Description string
|
||||
DisplayLow float64
|
||||
DisplayHigh float64
|
||||
DriveLow float64
|
||||
DriveHigh float64
|
||||
EnumStrings []string // non-nil only for TypeEnum
|
||||
Writable bool
|
||||
}
|
||||
|
||||
// CancelFunc cancels a subscription started by DataSource.Subscribe.
|
||||
type CancelFunc func()
|
||||
|
||||
// DataSource is implemented by every data source plugin.
|
||||
type DataSource interface {
|
||||
// Name returns the unique short identifier for this source (e.g. "epics").
|
||||
Name() string
|
||||
|
||||
// Connect initialises the connection to the underlying system.
|
||||
// It should return quickly; actual channel connections are lazy.
|
||||
Connect(ctx context.Context) error
|
||||
|
||||
// ListSignals returns metadata for all signals known to this source.
|
||||
ListSignals(ctx context.Context) ([]Metadata, error)
|
||||
|
||||
// GetMetadata returns the metadata for a single named signal.
|
||||
GetMetadata(ctx context.Context, signal string) (Metadata, error)
|
||||
|
||||
// Subscribe registers ch to receive value updates for the named signal.
|
||||
// The first value is delivered as soon as it is available.
|
||||
// The returned CancelFunc must be called to release resources.
|
||||
Subscribe(ctx context.Context, signal string, ch chan<- Value) (CancelFunc, error)
|
||||
|
||||
// Write sets a new value for a writable signal.
|
||||
Write(ctx context.Context, signal string, value any) error
|
||||
|
||||
// History returns up to maxPoints values in [start, end].
|
||||
// Returns ErrHistoryUnavailable if the source has no archive.
|
||||
History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]Value, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a signal does not exist in the source.
|
||||
ErrNotFound = errors.New("signal not found")
|
||||
|
||||
// ErrNotWritable is returned when attempting to write to a read-only signal.
|
||||
ErrNotWritable = errors.New("signal is not writable")
|
||||
|
||||
// ErrHistoryUnavailable is returned when the source has no archive backend.
|
||||
ErrHistoryUnavailable = errors.New("history unavailable for this data source")
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package stub provides a synthetic data source that emits deterministic test
|
||||
// signals (sine waves, ramp, boolean toggle) without any external dependencies.
|
||||
// It is used during development and for integration tests.
|
||||
package stub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const updateInterval = 100 * time.Millisecond // 10 Hz
|
||||
|
||||
type signalDef struct {
|
||||
meta datasource.Metadata
|
||||
fn func(t time.Time) any
|
||||
}
|
||||
|
||||
// Stub is a data source that emits canned signals for development and testing.
|
||||
type Stub struct {
|
||||
signals map[string]signalDef
|
||||
|
||||
// writable values (signal name → current value)
|
||||
mu sync.RWMutex
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
// New returns a Stub with a predefined set of test signals.
|
||||
func New() *Stub {
|
||||
s := &Stub{
|
||||
signals: make(map[string]signalDef),
|
||||
values: make(map[string]any),
|
||||
}
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "sine_1hz", Type: datasource.TypeFloat64,
|
||||
Unit: "V", DisplayLow: -1, DisplayHigh: 1,
|
||||
Description: "1 Hz sine wave",
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
return math.Sin(2 * math.Pi * float64(t.UnixNano()) / 1e9)
|
||||
},
|
||||
})
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "sine_01hz", Type: datasource.TypeFloat64,
|
||||
Unit: "A", DisplayLow: -1, DisplayHigh: 1,
|
||||
Description: "0.1 Hz sine wave",
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
return math.Sin(2 * math.Pi * 0.1 * float64(t.UnixNano()) / 1e9)
|
||||
},
|
||||
})
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "ramp_10s", Type: datasource.TypeFloat64,
|
||||
Unit: "mm", DisplayLow: 0, DisplayHigh: 100,
|
||||
Description: "10-second sawtooth ramp, 0–100",
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
return math.Mod(float64(t.UnixNano())/1e10, 100)
|
||||
},
|
||||
})
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "toggle_1hz", Type: datasource.TypeBool,
|
||||
Description: "1 Hz square wave (boolean)",
|
||||
},
|
||||
fn: func(t time.Time) any { return (t.Unix() % 2) == 0 },
|
||||
})
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "noise", Type: datasource.TypeFloat64,
|
||||
Unit: "dB", DisplayLow: -1, DisplayHigh: 1,
|
||||
Description: "White noise",
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
// Seed a per-tick RNG from the timestamp so results are
|
||||
// reproducible within a tick but vary across ticks.
|
||||
r := rand.New(rand.NewPCG(uint64(t.UnixNano()), 0))
|
||||
return r.Float64()*2 - 1
|
||||
},
|
||||
})
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "setpoint", Type: datasource.TypeFloat64,
|
||||
Unit: "°C", DisplayLow: 0, DisplayHigh: 200,
|
||||
DriveLow: 0, DriveHigh: 200,
|
||||
Description: "Writable setpoint (stub)",
|
||||
Writable: true,
|
||||
},
|
||||
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
|
||||
})
|
||||
s.values["setpoint"] = 25.0
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Stub) register(d signalDef) {
|
||||
s.signals[d.meta.Name] = d
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Stub) Name() string { return "stub" }
|
||||
|
||||
// Connect is a no-op for the stub source.
|
||||
func (s *Stub) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for all built-in stub signals.
|
||||
func (s *Stub) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, d := range s.signals {
|
||||
out = append(out, d.meta)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the metadata for the named signal.
|
||||
func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
d, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return d.meta, nil
|
||||
}
|
||||
|
||||
// Subscribe starts a 10 Hz ticker that pushes generated values into ch.
|
||||
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
def, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(updateInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case t := <-ticker.C:
|
||||
var data any
|
||||
if def.meta.Writable {
|
||||
s.mu.RLock()
|
||||
data = s.values[signal]
|
||||
s.mu.RUnlock()
|
||||
} else {
|
||||
data = def.fn(t)
|
||||
}
|
||||
v := datasource.Value{Timestamp: t, Data: data, Quality: datasource.QualityGood}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write updates the current value of a writable signal.
|
||||
func (s *Stub) Write(_ context.Context, signal string, value any) error {
|
||||
def, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
if !def.meta.Writable {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.values[signal] = value
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// History is not supported by the stub source.
|
||||
func (s *Stub) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
Reference in New Issue
Block a user