Implemented new datasources (modbus,scpi)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package scpi
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
|
||||
// Config is the [datasource.scpi] section. Each instrument is polled
|
||||
// independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for channels that do not
|
||||
// override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Instruments []Instrument `toml:"instruments"`
|
||||
}
|
||||
|
||||
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
|
||||
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
|
||||
type Instrument struct {
|
||||
Name string `toml:"name"`
|
||||
// Transport selects the link type. "raw" (default) is line-based SCPI over
|
||||
// TCP. Reserved: "vxi11" (not yet implemented).
|
||||
Transport string `toml:"transport"`
|
||||
Address string `toml:"address"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
// Terminator is appended to every command. Empty → "\n".
|
||||
Terminator string `toml:"terminator"`
|
||||
Channels []Channel `toml:"channels"`
|
||||
}
|
||||
|
||||
// Channel maps a SCPI query/command pair onto a signal named
|
||||
// "instrument:channel".
|
||||
type Channel struct {
|
||||
Name string `toml:"name"`
|
||||
// Query is the SCPI command whose response is the channel value,
|
||||
// e.g. "MEAS:VOLT?". Required.
|
||||
Query string `toml:"query"`
|
||||
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
|
||||
// the value, e.g. "VOLT %v". Empty → channel is read-only.
|
||||
WriteCmd string `toml:"write_cmd"`
|
||||
// Type is the value type: "float" (default), "string", "int", or "bool".
|
||||
Type string `toml:"type"`
|
||||
Unit string `toml:"unit"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
func (c Channel) dataType() datasource.DataType {
|
||||
switch c.Type {
|
||||
case "string":
|
||||
return datasource.TypeString
|
||||
case "int":
|
||||
return datasource.TypeInt64
|
||||
case "bool":
|
||||
return datasource.TypeBool
|
||||
default:
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
func (c Channel) writable() bool { return c.WriteCmd != "" }
|
||||
|
||||
func (c Channel) metadata(instrument string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: instrument + ":" + c.Name,
|
||||
Type: c.dataType(),
|
||||
Unit: c.Unit,
|
||||
Description: c.Description,
|
||||
DisplayLow: c.Min,
|
||||
DisplayHigh: c.Max,
|
||||
DriveLow: c.Min,
|
||||
DriveHigh: c.Max,
|
||||
Writable: c.writable(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// mockInstrument is an in-process line-based SCPI server. It answers queries
|
||||
// from a fixed table and records commands that produce no response (writes).
|
||||
type mockInstrument struct {
|
||||
ln net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
answers map[string]string // query → response
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newMockInstrument(t *testing.T) *mockInstrument {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
m := &mockInstrument{ln: ln, answers: map[string]string{}}
|
||||
go m.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
|
||||
|
||||
func (m *mockInstrument) setAnswer(q, a string) {
|
||||
m.mu.Lock()
|
||||
m.answers[q] = a
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *mockInstrument) writeLog() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.writes...)
|
||||
}
|
||||
|
||||
func (m *mockInstrument) serve() {
|
||||
for {
|
||||
conn, err := m.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go m.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockInstrument) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
m.mu.Lock()
|
||||
if strings.HasSuffix(cmd, "?") {
|
||||
resp, ok := m.answers[cmd]
|
||||
if !ok {
|
||||
resp = "0"
|
||||
}
|
||||
m.mu.Unlock()
|
||||
conn.Write([]byte(resp + "\n"))
|
||||
continue
|
||||
}
|
||||
m.writes = append(m.writes, cmd)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func testCfg(addr string) Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PollIntervalMs: 20,
|
||||
Instruments: []Instrument{{
|
||||
Name: "dmm",
|
||||
Address: addr,
|
||||
Channels: []Channel{
|
||||
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
|
||||
{Name: "id", Query: "*IDN?", Type: "string"},
|
||||
{Name: "n", Query: "COUNT?", Type: "int"},
|
||||
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTypes(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "12.34")
|
||||
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
|
||||
srv.setAnswer("COUNT?", "7")
|
||||
srv.setAnswer("OUTP?", "ON")
|
||||
|
||||
s, err := New(testCfg(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
v, err := s.readSignal(s.signals["dmm:volt"])
|
||||
if err != nil {
|
||||
t.Fatalf("read volt: %v", err)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
|
||||
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
|
||||
}
|
||||
|
||||
id, _ := s.readSignal(s.signals["dmm:id"])
|
||||
if id.Data != "ACME,DMM,1,2.0" {
|
||||
t.Errorf("id = %v", id.Data)
|
||||
}
|
||||
|
||||
n, _ := s.readSignal(s.signals["dmm:n"])
|
||||
if iv, ok := n.Data.(int64); !ok || iv != 7 {
|
||||
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
|
||||
}
|
||||
|
||||
out, _ := s.readSignal(s.signals["dmm:out"])
|
||||
if b, ok := out.Data.(bool); !ok || !b {
|
||||
t.Errorf("out = %v, want true", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
|
||||
t.Fatalf("write volt: %v", err)
|
||||
}
|
||||
// bool write uses a template with no verb → "OUTP <val>".
|
||||
if err := s.Write(ctx, "dmm:out", true); err != nil {
|
||||
t.Fatalf("write out: %v", err)
|
||||
}
|
||||
|
||||
// Give the server a moment to record both writes.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(srv.writeLog()) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got := srv.writeLog()
|
||||
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
|
||||
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrors(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
|
||||
t.Errorf("missing = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
|
||||
t.Errorf("read-only = %v, want ErrNotWritable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "5.0")
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("quality = %v", v.Quality)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f != 5.0 {
|
||||
t.Errorf("value = %v, want 5.0", v.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeBadQuality(t *testing.T) {
|
||||
cfg := testCfg("127.0.0.1:1") // refused
|
||||
s, _ := New(cfg)
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 1)
|
||||
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityBad {
|
||||
t.Errorf("quality = %v, want bad", v.Quality)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
|
||||
t.Error("want error for missing name")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
||||
t.Error("want error for duplicate instrument")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
|
||||
t.Error("want error for unimplemented vxi11 transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
|
||||
t.Error("want error for unknown transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
|
||||
t.Error("want error for channel without query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWrite(t *testing.T) {
|
||||
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
|
||||
t.Errorf("verb template = %q", got)
|
||||
}
|
||||
if got := formatWrite("OUTP", true); got != "OUTP true" {
|
||||
t.Errorf("plain template = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValue(t *testing.T) {
|
||||
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
|
||||
t.Errorf("OFF = %v, want false", v)
|
||||
}
|
||||
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
|
||||
t.Errorf("2.0 bool = %v, want true", v)
|
||||
}
|
||||
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
|
||||
t.Error("want parse error for non-numeric float")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// transport is the request/response channel to an instrument. The raw-socket
|
||||
// implementation below speaks line-oriented SCPI over TCP (the common
|
||||
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
|
||||
// (ONC-RPC) transport can be added later without touching the data source.
|
||||
type transport interface {
|
||||
// query sends cmd and returns the instrument's single-line response.
|
||||
query(cmd string) (string, error)
|
||||
// write sends cmd and does not wait for a response.
|
||||
write(cmd string) error
|
||||
close()
|
||||
}
|
||||
|
||||
// rawSocket is a line-based SCPI transport over a single TCP connection. The
|
||||
// connection is dialled lazily and dropped on any I/O error so the next call
|
||||
// reconnects. Calls are serialised by mu because SCPI is request/response.
|
||||
type rawSocket struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
terminator string
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
}
|
||||
|
||||
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
if terminator == "" {
|
||||
terminator = "\n"
|
||||
}
|
||||
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
|
||||
}
|
||||
|
||||
func (s *rawSocket) dialLocked() error {
|
||||
if s.conn != nil {
|
||||
return nil
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
|
||||
}
|
||||
s.conn = conn
|
||||
s.br = bufio.NewReader(conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) closeLocked() {
|
||||
if s.conn != nil {
|
||||
_ = s.conn.Close()
|
||||
s.conn = nil
|
||||
s.br = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rawSocket) close() {
|
||||
s.mu.Lock()
|
||||
s.closeLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *rawSocket) sendLocked(cmd string) error {
|
||||
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
|
||||
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
|
||||
s.closeLocked()
|
||||
return fmt.Errorf("scpi: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) write(cmd string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendLocked(cmd)
|
||||
}
|
||||
|
||||
func (s *rawSocket) query(cmd string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sendLocked(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.br.ReadString('\n')
|
||||
if err != nil {
|
||||
s.closeLocked()
|
||||
return "", fmt.Errorf("scpi: read: %w", err)
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user