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
+267
View File
@@ -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")
}
}