259 lines
6.7 KiB
Go
259 lines
6.7 KiB
Go
// 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)
|
|
}
|
|
}
|