Implemented new datasources (modbus,scpi)

This commit is contained in:
Martino Ferrari
2026-06-24 06:12:52 +02:00
parent 999a1510d4
commit cf9da3df0a
13 changed files with 1967 additions and 0 deletions
+237
View File
@@ -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
}
}