Implemented new datasources (modbus,scpi)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user