Implemented new datasources (modbus,scpi)
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// Package scpi implements a SCPI instrument data source. Each configured
|
||||
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
|
||||
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
|
||||
// exposed as signals named "instrument:channel" and polled at a configurable
|
||||
// interval. Channels with a write_cmd template accept Write.
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
type instrumentConn struct {
|
||||
inst Instrument
|
||||
tr transport
|
||||
}
|
||||
|
||||
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
|
||||
type Scpi struct {
|
||||
pollInterval time.Duration
|
||||
instruments map[string]*instrumentConn
|
||||
signals map[string]signalRef // "instrument:channel" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
instrument string
|
||||
ch Channel
|
||||
}
|
||||
|
||||
// New builds a SCPI source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Scpi, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
s := &Scpi{
|
||||
pollInterval: poll,
|
||||
instruments: make(map[string]*instrumentConn),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, inst := range cfg.Instruments {
|
||||
if inst.Name == "" || inst.Address == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument needs name and address")
|
||||
}
|
||||
if _, dup := s.instruments[inst.Name]; dup {
|
||||
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
|
||||
}
|
||||
tr, err := newTransport(inst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ic := &instrumentConn{inst: inst, tr: tr}
|
||||
for _, ch := range inst.Channels {
|
||||
if ch.Name == "" || ch.Query == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
|
||||
}
|
||||
key := inst.Name + ":" + ch.Name
|
||||
if _, dup := s.signals[key]; dup {
|
||||
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
|
||||
}
|
||||
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
|
||||
}
|
||||
s.instruments[inst.Name] = ic
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// newTransport selects the transport implementation for an instrument.
|
||||
func newTransport(inst Instrument) (transport, error) {
|
||||
addr := inst.Address
|
||||
switch strings.ToLower(inst.Transport) {
|
||||
case "", "raw":
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":5025"
|
||||
}
|
||||
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
|
||||
case "vxi11":
|
||||
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Scpi) Name() string { return "scpi" }
|
||||
|
||||
// Connect is a no-op; connections are dialled lazily per instrument.
|
||||
func (s *Scpi) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured channel.
|
||||
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, ref := range s.signals {
|
||||
out = append(out, ref.ch.metadata(ref.instrument))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.ch.metadata(ref.instrument), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous query of a channel.
|
||||
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
ic := s.instruments[ref.instrument]
|
||||
resp, err := ic.tr.query(ref.ch.Query)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := parseValue(ref.ch.dataType(), resp)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the channel at its configured interval (falling back to the
|
||||
// source default) and pushes values into ch. A query error emits QualityBad and
|
||||
// polling continues.
|
||||
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
interval := s.pollInterval
|
||||
if ref.ch.PollIntervalMs > 0 {
|
||||
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := s.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sends the channel's write_cmd template with the value substituted.
|
||||
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
if !ref.ch.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
ic := s.instruments[ref.instrument]
|
||||
cmd := formatWrite(ref.ch.WriteCmd, value)
|
||||
return ic.tr.write(cmd)
|
||||
}
|
||||
|
||||
// History is unavailable for SCPI instruments.
|
||||
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every instrument connection.
|
||||
func (s *Scpi) Close() {
|
||||
for _, ic := range s.instruments {
|
||||
ic.tr.close()
|
||||
}
|
||||
}
|
||||
|
||||
// formatWrite substitutes value into the write template. A "%" in the template
|
||||
// is treated as a printf verb; otherwise the value is appended after a space.
|
||||
func formatWrite(tmpl string, value any) string {
|
||||
if strings.Contains(tmpl, "%") {
|
||||
return fmt.Sprintf(tmpl, value)
|
||||
}
|
||||
return fmt.Sprintf("%s %v", tmpl, value)
|
||||
}
|
||||
|
||||
// parseValue converts a raw SCPI response string into the channel's data type.
|
||||
func parseValue(t datasource.DataType, resp string) (any, error) {
|
||||
resp = strings.TrimSpace(resp)
|
||||
switch t {
|
||||
case datasource.TypeString:
|
||||
return resp, nil
|
||||
case datasource.TypeInt64:
|
||||
// Accept "12", "12.0", or scientific notation by going through float.
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
|
||||
}
|
||||
return int64(f), nil
|
||||
case datasource.TypeBool:
|
||||
switch strings.ToUpper(resp) {
|
||||
case "1", "ON", "TRUE":
|
||||
return true, nil
|
||||
case "0", "OFF", "FALSE":
|
||||
return false, nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
|
||||
}
|
||||
return f != 0, nil
|
||||
default:
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user