365 lines
9.3 KiB
Go
365 lines
9.3 KiB
Go
package modbus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"io"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/datasource"
|
|
)
|
|
|
|
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
|
|
// register/coil store and records writes. Only the function codes exercised by
|
|
// the data source are implemented.
|
|
type mockServer struct {
|
|
ln net.Listener
|
|
|
|
mu sync.Mutex
|
|
holding map[uint16]uint16
|
|
input map[uint16]uint16
|
|
coils map[uint16]bool
|
|
discrete map[uint16]bool
|
|
lastWrite []uint16 // registers from the most recent write
|
|
}
|
|
|
|
func newMockServer(t *testing.T) *mockServer {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
s := &mockServer{
|
|
ln: ln,
|
|
holding: map[uint16]uint16{},
|
|
input: map[uint16]uint16{},
|
|
coils: map[uint16]bool{},
|
|
discrete: map[uint16]bool{},
|
|
}
|
|
go s.serve()
|
|
t.Cleanup(func() { ln.Close() })
|
|
return s
|
|
}
|
|
|
|
func (s *mockServer) addr() string { return s.ln.Addr().String() }
|
|
|
|
func (s *mockServer) serve() {
|
|
for {
|
|
conn, err := s.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go s.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (s *mockServer) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
for {
|
|
head := make([]byte, 7)
|
|
if _, err := io.ReadFull(conn, head); err != nil {
|
|
return
|
|
}
|
|
tx := binary.BigEndian.Uint16(head[0:])
|
|
length := binary.BigEndian.Uint16(head[4:])
|
|
body := make([]byte, length-1)
|
|
if _, err := io.ReadFull(conn, body); err != nil {
|
|
return
|
|
}
|
|
resp := s.respond(body)
|
|
out := make([]byte, 7+len(resp))
|
|
binary.BigEndian.PutUint16(out[0:], tx)
|
|
binary.BigEndian.PutUint16(out[2:], 0)
|
|
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
|
|
out[6] = head[6]
|
|
copy(out[7:], resp)
|
|
if _, err := conn.Write(out); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *mockServer) respond(pdu []byte) []byte {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
fc := pdu[0]
|
|
switch fc {
|
|
case fcReadHolding, fcReadInput:
|
|
addr := binary.BigEndian.Uint16(pdu[1:])
|
|
qty := binary.BigEndian.Uint16(pdu[3:])
|
|
out := []byte{fc, byte(qty * 2)}
|
|
src := s.holding
|
|
if fc == fcReadInput {
|
|
src = s.input
|
|
}
|
|
for i := uint16(0); i < qty; i++ {
|
|
out = binary.BigEndian.AppendUint16(out, src[addr+i])
|
|
}
|
|
return out
|
|
case fcReadCoils, fcReadDiscrete:
|
|
addr := binary.BigEndian.Uint16(pdu[1:])
|
|
qty := binary.BigEndian.Uint16(pdu[3:])
|
|
nbytes := (int(qty) + 7) / 8
|
|
out := []byte{fc, byte(nbytes)}
|
|
bits := make([]byte, nbytes)
|
|
src := s.coils
|
|
if fc == fcReadDiscrete {
|
|
src = s.discrete
|
|
}
|
|
for i := uint16(0); i < qty; i++ {
|
|
if src[addr+i] {
|
|
bits[i/8] |= 1 << (i % 8)
|
|
}
|
|
}
|
|
return append(out, bits...)
|
|
case fcWriteSingleReg:
|
|
addr := binary.BigEndian.Uint16(pdu[1:])
|
|
val := binary.BigEndian.Uint16(pdu[3:])
|
|
s.holding[addr] = val
|
|
s.lastWrite = []uint16{val}
|
|
return pdu // echo
|
|
case fcWriteSingleCoil:
|
|
addr := binary.BigEndian.Uint16(pdu[1:])
|
|
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
|
|
return pdu
|
|
case fcWriteMultipleRegs:
|
|
addr := binary.BigEndian.Uint16(pdu[1:])
|
|
qty := binary.BigEndian.Uint16(pdu[3:])
|
|
s.lastWrite = nil
|
|
for i := uint16(0); i < qty; i++ {
|
|
v := binary.BigEndian.Uint16(pdu[6+i*2:])
|
|
s.holding[addr+i] = v
|
|
s.lastWrite = append(s.lastWrite, v)
|
|
}
|
|
return append([]byte{fc}, pdu[1:5]...)
|
|
default:
|
|
return []byte{fc | 0x80, 0x01}
|
|
}
|
|
}
|
|
|
|
func (s *mockServer) setHolding(addr, val uint16) {
|
|
s.mu.Lock()
|
|
s.holding[addr] = val
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *mockServer) setInput(addr, val uint16) {
|
|
s.mu.Lock()
|
|
s.input[addr] = val
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *mockServer) setDiscrete(addr uint16, on bool) {
|
|
s.mu.Lock()
|
|
s.discrete[addr] = on
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func testConfig(addr string) Config {
|
|
return Config{
|
|
Enabled: true,
|
|
PollIntervalMs: 20,
|
|
Devices: []Device{{
|
|
Name: "dev",
|
|
Address: addr,
|
|
UnitID: 1,
|
|
Registers: []Register{
|
|
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
|
|
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
|
|
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
|
|
{Name: "flag", Kind: "discrete", Address: 5},
|
|
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
|
|
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestReadRegisters(t *testing.T) {
|
|
srv := newMockServer(t)
|
|
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
|
|
srv.setHolding(20, 7)
|
|
srv.setInput(30, 0)
|
|
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
|
|
srv.setDiscrete(5, true)
|
|
|
|
m, err := New(testConfig(srv.addr()))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
temp, err := m.readSignal(m.signals["dev:temp"])
|
|
if err != nil {
|
|
t.Fatalf("read temp: %v", err)
|
|
}
|
|
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
|
|
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
|
|
}
|
|
|
|
count, err := m.readSignal(m.signals["dev:count"])
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if v, ok := count.Data.(int64); !ok || v != 7 {
|
|
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
|
|
}
|
|
|
|
big, err := m.readSignal(m.signals["dev:big"])
|
|
if err != nil {
|
|
t.Fatalf("read big: %v", err)
|
|
}
|
|
if v, ok := big.Data.(int64); !ok || v != 1000 {
|
|
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
|
|
}
|
|
|
|
flag, err := m.readSignal(m.signals["dev:flag"])
|
|
if err != nil {
|
|
t.Fatalf("read flag: %v", err)
|
|
}
|
|
if b, ok := flag.Data.(bool); !ok || !b {
|
|
t.Errorf("flag = %v, want true", flag.Data)
|
|
}
|
|
}
|
|
|
|
func TestWriteRoundTrip(t *testing.T) {
|
|
srv := newMockServer(t)
|
|
m, err := New(testConfig(srv.addr()))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer m.Close()
|
|
ctx := context.Background()
|
|
|
|
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
|
|
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
|
|
t.Fatalf("write temp: %v", err)
|
|
}
|
|
srv.mu.Lock()
|
|
raw := srv.holding[10]
|
|
srv.mu.Unlock()
|
|
if raw != 235 {
|
|
t.Errorf("holding[10] = %d, want 235", raw)
|
|
}
|
|
|
|
// Coil write.
|
|
if err := m.Write(ctx, "dev:relay", true); err != nil {
|
|
t.Fatalf("write relay: %v", err)
|
|
}
|
|
srv.mu.Lock()
|
|
on := srv.coils[6]
|
|
srv.mu.Unlock()
|
|
if !on {
|
|
t.Error("coil 6 not set")
|
|
}
|
|
|
|
// float32 multi-register write.
|
|
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
|
|
t.Fatalf("write sp: %v", err)
|
|
}
|
|
got, err := m.readSignal(m.signals["dev:sp"])
|
|
if err != nil {
|
|
t.Fatalf("read sp: %v", err)
|
|
}
|
|
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
|
|
t.Errorf("sp = %v, want 12.5", got.Data)
|
|
}
|
|
}
|
|
|
|
func TestWriteErrors(t *testing.T) {
|
|
srv := newMockServer(t)
|
|
m, _ := New(testConfig(srv.addr()))
|
|
defer m.Close()
|
|
ctx := context.Background()
|
|
|
|
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
|
|
t.Errorf("missing write err = %v, want ErrNotFound", err)
|
|
}
|
|
// Input register is read-only.
|
|
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
|
|
t.Errorf("input write err = %v, want ErrNotWritable", err)
|
|
}
|
|
}
|
|
|
|
func TestSubscribe(t *testing.T) {
|
|
srv := newMockServer(t)
|
|
srv.setHolding(20, 42)
|
|
m, _ := New(testConfig(srv.addr()))
|
|
defer m.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
ch := make(chan datasource.Value, 4)
|
|
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
|
if err != nil {
|
|
t.Fatalf("subscribe: %v", err)
|
|
}
|
|
defer stop()
|
|
|
|
select {
|
|
case v := <-ch:
|
|
if v.Quality != datasource.QualityGood {
|
|
t.Errorf("quality = %v, want good", v.Quality)
|
|
}
|
|
if iv, ok := v.Data.(int64); !ok || iv != 42 {
|
|
t.Errorf("first value = %v, want 42", v.Data)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for first value")
|
|
}
|
|
}
|
|
|
|
func TestSubscribeBadQualityOnError(t *testing.T) {
|
|
// Point at a closed port so reads fail; expect QualityBad, not a hang.
|
|
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
|
|
m, _ := New(cfg)
|
|
defer m.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
ch := make(chan datasource.Value, 1)
|
|
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
|
if err != nil {
|
|
t.Fatalf("subscribe: %v", err)
|
|
}
|
|
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 TestNewValidation(t *testing.T) {
|
|
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
|
|
t.Error("expected error for missing device name")
|
|
}
|
|
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
|
t.Error("expected error for duplicate device")
|
|
}
|
|
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
|
|
t.Error("expected error for bad encoding")
|
|
}
|
|
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
|
|
t.Error("expected error for duplicate register")
|
|
}
|
|
}
|
|
|
|
func TestSubscribeUnknownSignal(t *testing.T) {
|
|
m, _ := New(testConfig("127.0.0.1:502"))
|
|
defer m.Close()
|
|
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
|
|
t.Errorf("err = %v, want ErrNotFound", err)
|
|
}
|
|
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
|
|
t.Errorf("meta err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|