Implemented new datasources (modbus,scpi)
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Modbus function codes.
|
||||
const (
|
||||
fcReadCoils = 0x01
|
||||
fcReadDiscrete = 0x02
|
||||
fcReadHolding = 0x03
|
||||
fcReadInput = 0x04
|
||||
fcWriteSingleCoil = 0x05
|
||||
fcWriteSingleReg = 0x06
|
||||
fcWriteMultipleRegs = 0x10
|
||||
)
|
||||
|
||||
// client is a minimal Modbus TCP master for a single device address. Requests
|
||||
// are serialised by mu (Modbus TCP is request/response and the connection is
|
||||
// shared by all of a device's polled registers). The connection is dialled
|
||||
// lazily and dropped on any I/O error so the next request reconnects.
|
||||
type client struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
txID uint16
|
||||
}
|
||||
|
||||
func newClient(addr string, timeout time.Duration) *client {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return &client{addr: addr, timeout: timeout}
|
||||
}
|
||||
|
||||
func (c *client) close() {
|
||||
c.mu.Lock()
|
||||
c.closeLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *client) closeLocked() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// request sends a PDU to unitID and returns the response PDU (function code +
|
||||
// data). It dials on demand and tears the connection down on error.
|
||||
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
|
||||
}
|
||||
c.conn = conn
|
||||
}
|
||||
|
||||
resp, err := c.transact(unitID, pdu)
|
||||
if err != nil {
|
||||
c.closeLocked()
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// transact performs one MBAP-framed exchange. Caller holds mu.
|
||||
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.txID++
|
||||
tx := c.txID
|
||||
|
||||
frame := make([]byte, 7+len(pdu))
|
||||
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
|
||||
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
|
||||
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
|
||||
frame[6] = unitID
|
||||
copy(frame[7:], pdu)
|
||||
|
||||
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
|
||||
if _, err := c.conn.Write(frame); err != nil {
|
||||
return nil, fmt.Errorf("modbus: write: %w", err)
|
||||
}
|
||||
|
||||
head := make([]byte, 7)
|
||||
if _, err := io.ReadFull(c.conn, head); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read header: %w", err)
|
||||
}
|
||||
if binary.BigEndian.Uint16(head[0:]) != tx {
|
||||
return nil, fmt.Errorf("modbus: transaction id mismatch")
|
||||
}
|
||||
length := binary.BigEndian.Uint16(head[4:])
|
||||
if length < 2 { // unit id + at least a function code
|
||||
return nil, fmt.Errorf("modbus: short frame length %d", length)
|
||||
}
|
||||
body := make([]byte, length-1) // header already consumed the unit id
|
||||
if _, err := io.ReadFull(c.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read body: %w", err)
|
||||
}
|
||||
|
||||
fc := body[0]
|
||||
if fc&0x80 != 0 { // exception response
|
||||
var ex byte
|
||||
if len(body) >= 2 {
|
||||
ex = body[1]
|
||||
}
|
||||
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
|
||||
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short register response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
|
||||
}
|
||||
regs := make([]uint16, quantity)
|
||||
for i := range regs {
|
||||
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
|
||||
}
|
||||
return regs, nil
|
||||
}
|
||||
|
||||
// readBits reads `quantity` bits via fc (coils or discrete inputs).
|
||||
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short bit response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
|
||||
}
|
||||
bits := make([]bool, quantity)
|
||||
for i := range bits {
|
||||
idx := 2 + i/8
|
||||
if idx >= len(resp) {
|
||||
break
|
||||
}
|
||||
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
|
||||
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
|
||||
pdu := make([]byte, 6+len(values)*2)
|
||||
pdu[0] = fcWriteMultipleRegs
|
||||
binary.BigEndian.PutUint16(pdu[1:], addr)
|
||||
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
|
||||
pdu[5] = byte(len(values) * 2)
|
||||
for i, v := range values {
|
||||
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
|
||||
}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
|
||||
var v uint16
|
||||
if on {
|
||||
v = 0xFF00
|
||||
}
|
||||
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func exceptionText(code byte) string {
|
||||
switch code {
|
||||
case 0x01:
|
||||
return "illegal function"
|
||||
case 0x02:
|
||||
return "illegal data address"
|
||||
case 0x03:
|
||||
return "illegal data value"
|
||||
case 0x04:
|
||||
return "server device failure"
|
||||
case 0x05:
|
||||
return "acknowledge"
|
||||
case 0x06:
|
||||
return "server device busy"
|
||||
case 0x0B:
|
||||
return "gateway target failed to respond"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// Config is the [datasource.modbus] section. Devices share no connection state;
|
||||
// each is polled independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for every register that does
|
||||
// not override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Devices []Device `toml:"devices"`
|
||||
}
|
||||
|
||||
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
|
||||
// appended if absent). UnitID is the Modbus unit/slave identifier (0–255).
|
||||
type Device struct {
|
||||
Name string `toml:"name"`
|
||||
Address string `toml:"address"`
|
||||
UnitID uint8 `toml:"unit_id"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
Registers []Register `toml:"registers"`
|
||||
}
|
||||
|
||||
// Register describes one logical signal mapped onto a Modbus address.
|
||||
//
|
||||
// - Kind selects the address space / function code:
|
||||
// "holding" (FC03/06/10), "input" (FC04, read-only),
|
||||
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
|
||||
// - Encoding selects how holding/input words are decoded:
|
||||
// "uint16", "int16", "uint32", "int32", "float32", "float64".
|
||||
// Ignored for coil/discrete (always bool).
|
||||
// - WordOrder is "big" (default, high word first) or "little" for the
|
||||
// multi-word encodings.
|
||||
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
|
||||
// Scale 0 is treated as 1.
|
||||
type Register struct {
|
||||
Name string `toml:"name"`
|
||||
Kind string `toml:"kind"`
|
||||
Address uint16 `toml:"address"`
|
||||
Encoding string `toml:"encoding"`
|
||||
WordOrder string `toml:"word_order"`
|
||||
Unit string `toml:"unit"`
|
||||
Scale float64 `toml:"scale"`
|
||||
Offset float64 `toml:"offset"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
Writable bool `toml:"writable"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
// register kinds.
|
||||
const (
|
||||
kindHolding = "holding"
|
||||
kindInput = "input"
|
||||
kindCoil = "coil"
|
||||
kindDiscrete = "discrete"
|
||||
)
|
||||
|
||||
// isBool reports whether the register addresses a single-bit space.
|
||||
func (r Register) isBool() bool {
|
||||
return r.kind() == kindCoil || r.kind() == kindDiscrete
|
||||
}
|
||||
|
||||
func (r Register) kind() string {
|
||||
if r.Kind == "" {
|
||||
return kindHolding
|
||||
}
|
||||
return strings.ToLower(r.Kind)
|
||||
}
|
||||
|
||||
func (r Register) encoding() string {
|
||||
if r.Encoding == "" {
|
||||
return "uint16"
|
||||
}
|
||||
return strings.ToLower(r.Encoding)
|
||||
}
|
||||
|
||||
func (r Register) littleWordOrder() bool {
|
||||
return strings.ToLower(r.WordOrder) == "little"
|
||||
}
|
||||
|
||||
func (r Register) scale() float64 {
|
||||
if r.Scale == 0 {
|
||||
return 1
|
||||
}
|
||||
return r.Scale
|
||||
}
|
||||
|
||||
// wordCount returns the number of 16-bit registers the encoding occupies.
|
||||
func (r Register) wordCount() (int, error) {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16":
|
||||
return 1, nil
|
||||
case "uint32", "int32", "float32":
|
||||
return 2, nil
|
||||
case "float64":
|
||||
return 4, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// dataType maps the register to a datasource value type.
|
||||
func (r Register) dataType() datasource.DataType {
|
||||
if r.isBool() {
|
||||
return datasource.TypeBool
|
||||
}
|
||||
// Integer encodings with no fractional scaling stay integers; anything
|
||||
// scaled, offset, or float-encoded becomes a float64.
|
||||
switch r.encoding() {
|
||||
case "float32", "float64":
|
||||
return datasource.TypeFloat64
|
||||
default:
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
return datasource.TypeInt64
|
||||
}
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
// writable reports whether writes are permitted. Input registers and discrete
|
||||
// inputs are read-only regardless of the Writable flag.
|
||||
func (r Register) writable() bool {
|
||||
switch r.kind() {
|
||||
case kindInput, kindDiscrete:
|
||||
return false
|
||||
default:
|
||||
return r.Writable
|
||||
}
|
||||
}
|
||||
|
||||
// metadata builds the datasource.Metadata for this register under signal name
|
||||
// "device:register".
|
||||
func (r Register) metadata(device string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: device + ":" + r.Name,
|
||||
Type: r.dataType(),
|
||||
Unit: r.Unit,
|
||||
Description: r.Description,
|
||||
DisplayLow: r.Min,
|
||||
DisplayHigh: r.Max,
|
||||
DriveLow: r.Min,
|
||||
DriveHigh: r.Max,
|
||||
Writable: r.writable(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// orderWords returns the registers in big-word-first order, reversing them when
|
||||
// the register declares little word order. The slice is copied so the caller's
|
||||
// data is left untouched.
|
||||
func (r Register) orderWords(words []uint16) []uint16 {
|
||||
if !r.littleWordOrder() {
|
||||
return words
|
||||
}
|
||||
out := make([]uint16, len(words))
|
||||
for i, w := range words {
|
||||
out[len(words)-1-i] = w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode converts the raw registers into the register's numeric value and
|
||||
// applies scale/offset. The result type is int64 or float64 per dataType.
|
||||
func (r Register) decode(words []uint16) (any, error) {
|
||||
w := r.orderWords(words)
|
||||
var raw float64
|
||||
var rawInt int64
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
rawInt = int64(w[0])
|
||||
raw = float64(w[0])
|
||||
case "int16":
|
||||
rawInt = int64(int16(w[0]))
|
||||
raw = float64(int16(w[0]))
|
||||
case "uint32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(u)
|
||||
raw = float64(u)
|
||||
case "int32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(int32(u))
|
||||
raw = float64(int32(u))
|
||||
case "float32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
raw = float64(math.Float32frombits(u))
|
||||
case "float64":
|
||||
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
|
||||
raw = math.Float64frombits(u)
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
|
||||
// Integer fast-path: no scaling/offset, integer encoding.
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16", "uint32", "int32":
|
||||
return rawInt, nil
|
||||
}
|
||||
}
|
||||
return raw*r.scale() + r.Offset, nil
|
||||
}
|
||||
|
||||
// encode converts a value destined for a Write back into raw registers,
|
||||
// inverting scale/offset. Only the holding-register encodings are writable.
|
||||
func (r Register) encode(value float64) ([]uint16, error) {
|
||||
v := (value - r.Offset) / r.scale()
|
||||
var words []uint16
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
words = []uint16{uint16(int64(math.Round(v)))}
|
||||
case "int16":
|
||||
words = []uint16{uint16(int16(int64(math.Round(v))))}
|
||||
case "uint32":
|
||||
u := uint32(int64(math.Round(v)))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "int32":
|
||||
u := uint32(int32(int64(math.Round(v))))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float32":
|
||||
u := math.Float32bits(float32(v))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float64":
|
||||
u := math.Float64bits(v)
|
||||
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
return r.orderWords(words), nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
func TestDecodeEncodings(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reg Register
|
||||
words []uint16
|
||||
want any
|
||||
}{
|
||||
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
|
||||
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
|
||||
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
|
||||
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
|
||||
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
|
||||
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
|
||||
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := tc.reg.decode(tc.words)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode error %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
switch w := tc.want.(type) {
|
||||
case int64:
|
||||
if got != w {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
case float64:
|
||||
gf, ok := got.(float64)
|
||||
if !ok || math.Abs(gf-w) > 1e-6 {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordOrder(t *testing.T) {
|
||||
big := Register{Encoding: "uint32", WordOrder: "big"}
|
||||
little := Register{Encoding: "uint32", WordOrder: "little"}
|
||||
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
|
||||
gb, _ := big.decode(words)
|
||||
gl, _ := little.decode(words)
|
||||
if gb != int64(0x00010002) {
|
||||
t.Errorf("big = %v, want %d", gb, 0x00010002)
|
||||
}
|
||||
if gl != int64(0x00020001) {
|
||||
t.Errorf("little = %v, want %d", gl, 0x00020001)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRoundTrip(t *testing.T) {
|
||||
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
|
||||
reg := Register{Encoding: enc}
|
||||
words, err := reg.encode(123)
|
||||
if err != nil {
|
||||
t.Fatalf("%s encode: %v", enc, err)
|
||||
}
|
||||
got, err := reg.decode(words)
|
||||
if err != nil {
|
||||
t.Fatalf("%s decode: %v", enc, err)
|
||||
}
|
||||
var f float64
|
||||
switch v := got.(type) {
|
||||
case int64:
|
||||
f = float64(v)
|
||||
case float64:
|
||||
f = v
|
||||
}
|
||||
if math.Abs(f-123) > 1e-3 {
|
||||
t.Errorf("%s round-trip = %v, want 123", enc, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTypeAndWritable(t *testing.T) {
|
||||
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
|
||||
t.Errorf("coil type = %v, want bool", got)
|
||||
}
|
||||
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("float type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("scaled int type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
|
||||
t.Errorf("plain int type = %v, want int64", got)
|
||||
}
|
||||
if (Register{Kind: "input", Writable: true}).writable() {
|
||||
t.Error("input register must be read-only")
|
||||
}
|
||||
if (Register{Kind: "discrete", Writable: true}).writable() {
|
||||
t.Error("discrete input must be read-only")
|
||||
}
|
||||
if !(Register{Kind: "holding", Writable: true}).writable() {
|
||||
t.Error("writable holding should be writable")
|
||||
}
|
||||
}
|
||||
|
||||
func f32Words(v float32) []uint16 {
|
||||
u := math.Float32bits(v)
|
||||
return []uint16{uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
|
||||
func f64Words(v float64) []uint16 {
|
||||
u := math.Float64bits(v)
|
||||
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package modbus implements a Modbus TCP data source. Each configured device is
|
||||
// polled over its own TCP connection; registers are exposed as signals named
|
||||
// "device:register". Reads use the holding/input/coil/discrete function codes;
|
||||
// writable holding registers and coils accept Write.
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
// deviceClient bundles a parsed device with its wire client and register lookup.
|
||||
type deviceClient struct {
|
||||
dev Device
|
||||
cli *client
|
||||
byName map[string]Register
|
||||
}
|
||||
|
||||
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
|
||||
type Modbus struct {
|
||||
pollInterval time.Duration
|
||||
devices map[string]*deviceClient // device name → client
|
||||
signals map[string]signalRef // "device:register" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
device string
|
||||
reg Register
|
||||
}
|
||||
|
||||
// New builds a Modbus source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Modbus, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
m := &Modbus{
|
||||
pollInterval: poll,
|
||||
devices: make(map[string]*deviceClient),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, dev := range cfg.Devices {
|
||||
if dev.Name == "" || dev.Address == "" {
|
||||
return nil, fmt.Errorf("modbus: device needs name and address")
|
||||
}
|
||||
if _, dup := m.devices[dev.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
|
||||
}
|
||||
addr := dev.Address
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":502"
|
||||
}
|
||||
dc := &deviceClient{
|
||||
dev: dev,
|
||||
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
|
||||
byName: make(map[string]Register),
|
||||
}
|
||||
for _, reg := range dev.Registers {
|
||||
if reg.Name == "" {
|
||||
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
|
||||
}
|
||||
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := dc.byName[reg.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
|
||||
}
|
||||
dc.byName[reg.Name] = reg
|
||||
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
|
||||
}
|
||||
m.devices[dev.Name] = dc
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (m *Modbus) Name() string { return "modbus" }
|
||||
|
||||
// Connect is a no-op; TCP connections are dialled lazily per device.
|
||||
func (m *Modbus) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured register.
|
||||
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(m.signals))
|
||||
for _, ref := range m.signals {
|
||||
out = append(out, ref.reg.metadata(ref.device))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.reg.metadata(ref.device), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous read of a register's current value.
|
||||
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
dc := m.devices[ref.device]
|
||||
reg := ref.reg
|
||||
now := time.Now()
|
||||
|
||||
if reg.isBool() {
|
||||
fc := byte(fcReadCoils)
|
||||
if reg.kind() == kindDiscrete {
|
||||
fc = fcReadDiscrete
|
||||
}
|
||||
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
count, err := reg.wordCount()
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
fc := byte(fcReadHolding)
|
||||
if reg.kind() == kindInput {
|
||||
fc = fcReadInput
|
||||
}
|
||||
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := reg.decode(words)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the register at the configured interval and pushes values
|
||||
// into ch. On a read error a QualityBad value is emitted and polling continues.
|
||||
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(m.pollInterval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := m.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit() // first reading without waiting a full interval
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sets a writable holding register or coil.
|
||||
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
reg := ref.reg
|
||||
if !reg.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
dc := m.devices[ref.device]
|
||||
|
||||
if reg.isBool() {
|
||||
on, err := toBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
|
||||
}
|
||||
|
||||
f, err := toFloat(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
words, err := reg.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(words) == 1 {
|
||||
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
|
||||
}
|
||||
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
|
||||
}
|
||||
|
||||
// History is unavailable for Modbus devices.
|
||||
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every device connection.
|
||||
func (m *Modbus) Close() {
|
||||
for _, dc := range m.devices {
|
||||
dc.cli.close()
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, error) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, nil
|
||||
case float32:
|
||||
return float64(x), nil
|
||||
case int:
|
||||
return float64(x), nil
|
||||
case int64:
|
||||
return float64(x), nil
|
||||
case bool:
|
||||
if x {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func toBool(v any) (bool, error) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, nil
|
||||
case float64:
|
||||
return x != 0, nil
|
||||
case int:
|
||||
return x != 0, nil
|
||||
case int64:
|
||||
return x != 0, nil
|
||||
default:
|
||||
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user