OK
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type DataType string
|
||||
|
||||
const (
|
||||
Bool DataType = "BOOL"
|
||||
Int DataType = "INT"
|
||||
Real DataType = "REAL"
|
||||
)
|
||||
|
||||
type DeviceConnectionConfig struct {
|
||||
DeviceID uuid.UUID
|
||||
Host string
|
||||
Port int
|
||||
ConnectTimeoutSeconds int
|
||||
ProtocolConfig map[string]any
|
||||
}
|
||||
type Connection interface {
|
||||
Read(context.Context, string, DataType) (float64, error)
|
||||
Write(context.Context, string, DataType, float64) error
|
||||
Close() error
|
||||
}
|
||||
type Factory interface {
|
||||
ProtocolType() string
|
||||
ValidateConfig(map[string]any) error
|
||||
ValidateAddress(string, DataType, bool) error
|
||||
ConfigSchema() map[string]any
|
||||
NewConnection(context.Context, DeviceConnectionConfig) (Connection, error)
|
||||
}
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]Factory
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry { return &Registry{items: map[string]Factory{}} }
|
||||
func (r *Registry) Register(f Factory) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.items[f.ProtocolType()]; ok {
|
||||
return fmt.Errorf("协议已注册: %s", f.ProtocolType())
|
||||
}
|
||||
r.items[f.ProtocolType()] = f
|
||||
return nil
|
||||
}
|
||||
func (r *Registry) Get(t string) (Factory, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
f, ok := r.items[t]
|
||||
if !ok {
|
||||
return nil, errors.New("不支持的协议")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
func (r *Registry) Metadata() []map[string]any {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := []map[string]any{}
|
||||
for _, f := range r.items {
|
||||
port := 502
|
||||
if f.ProtocolType() == "S7" {
|
||||
port = 102
|
||||
}
|
||||
out = append(out, map[string]any{"protocol_type": f.ProtocolType(), "default_port": port, "config_schema": f.ConfigSchema()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var s7DB = regexp.MustCompile(`^DB\d+\.\d+(?:\.[0-7])?$`)
|
||||
var s7Standard = regexp.MustCompile(`^[MIQ]\d+(?:\.[0-7])?$`)
|
||||
var s7Word = regexp.MustCompile(`^[MQ][WD]\d+$`)
|
||||
|
||||
func ValidateS7Address(a string, t DataType, w bool) error {
|
||||
if !s7DB.MatchString(a) && !s7Standard.MatchString(a) && !s7Word.MatchString(a) {
|
||||
return errors.New("S7 地址格式无效")
|
||||
}
|
||||
hasBit := regexp.MustCompile(`\.[0-7]$`).MatchString(a)
|
||||
numericDBZeroSuffix := t != Bool && regexp.MustCompile(`^DB\d+\.\d+\.0$`).MatchString(a)
|
||||
if t == Bool && !hasBit {
|
||||
return errors.New("BOOL 地址必须包含 bit")
|
||||
}
|
||||
if t != Bool && hasBit && !numericDBZeroSuffix {
|
||||
return errors.New("INT/REAL 地址不能包含 bit(仅兼容 DB 数值地址末尾 .0)")
|
||||
}
|
||||
if t == Int && regexp.MustCompile(`^[MQ]D`).MatchString(a) {
|
||||
return errors.New("INT 应使用 MW/QW 地址")
|
||||
}
|
||||
if t == Real && regexp.MustCompile(`^[MQ]W`).MatchString(a) {
|
||||
return errors.New("REAL 应使用 MD/QD 地址")
|
||||
}
|
||||
if w && len(a) > 0 && a[0] == 'I' {
|
||||
return errors.New("I 区只读")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateS7Address(t *testing.T) {
|
||||
tests := []struct {
|
||||
address string
|
||||
dataType DataType
|
||||
writable, wantError bool
|
||||
}{{"DB2.1186.0", Real, false, false}, {"DB2.1186.1", Real, false, true}, {"MD540", Real, true, false}, {"MW540", Int, true, false}, {"M4.0", Bool, true, false}, {"I1.0", Bool, true, true}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.address+string(tt.dataType), func(t *testing.T) {
|
||||
err := ValidateS7Address(tt.address, tt.dataType, tt.writable)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Fatalf("ValidateS7Address() error=%v wantError=%v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"aquacontrolai/internal/protocol"
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func (Factory) ProtocolType() string { return "MODBUS_TCP" }
|
||||
func (Factory) ValidateConfig(c map[string]any) error {
|
||||
u, ok := c["unit_id"].(float64)
|
||||
if !ok || u < 1 || u > 247 {
|
||||
return errors.New("unit_id 必须在1~247")
|
||||
}
|
||||
o, ok := c["float32_order"].(string)
|
||||
if !ok || !map[string]bool{"ABCD": true, "BADC": true, "CDAB": true, "DCBA": true}[o] {
|
||||
return errors.New("float32_order 无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (Factory) ValidateAddress(a string, t protocol.DataType, w bool) error {
|
||||
n, e := strconv.Atoi(a)
|
||||
if e != nil || n < 1 || n > 49999 {
|
||||
return errors.New("Modbus 地址无效")
|
||||
}
|
||||
prefix := n / 10000
|
||||
if prefix <= 1 && t != protocol.Bool {
|
||||
return errors.New("线圈/离散输入仅支持BOOL")
|
||||
}
|
||||
if prefix >= 3 && t == protocol.Bool {
|
||||
return errors.New("寄存器仅支持INT/REAL")
|
||||
}
|
||||
if w && (prefix == 1 || prefix == 3) {
|
||||
return errors.New("该区域只读")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (Factory) ConfigSchema() map[string]any {
|
||||
return map[string]any{"type": "object", "required": []string{"unit_id", "float32_order"}}
|
||||
}
|
||||
func (Factory) NewConnection(context.Context, protocol.DeviceConnectionConfig) (protocol.Connection, error) {
|
||||
return nil, errors.New("Modbus 连接由运行时适配器创建")
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package s7
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/robinson/gos7"
|
||||
|
||||
"aquacontrolai/internal/protocol"
|
||||
)
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func (Factory) ProtocolType() string { return "S7" }
|
||||
func (Factory) ValidateConfig(c map[string]any) error {
|
||||
if _, ok := c["rack"]; !ok {
|
||||
return errors.New("缺少 rack")
|
||||
}
|
||||
if _, ok := c["slot"]; !ok {
|
||||
return errors.New("缺少 slot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (Factory) ValidateAddress(a string, t protocol.DataType, w bool) error {
|
||||
return protocol.ValidateS7Address(a, t, w)
|
||||
}
|
||||
func (Factory) ConfigSchema() map[string]any {
|
||||
return map[string]any{"type": "object", "required": []string{"rack", "slot"}, "properties": map[string]any{"rack": map[string]any{"type": "integer", "default": 0}, "slot": map[string]any{"type": "integer", "default": 1}}}
|
||||
}
|
||||
func (Factory) NewConnection(ctx context.Context, cfg protocol.DeviceConnectionConfig) (protocol.Connection, error) {
|
||||
handler := gos7.NewTCPClientHandler(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), intConfig(cfg.ProtocolConfig, "rack"), intConfig(cfg.ProtocolConfig, "slot"))
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- handler.Connect() }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = handler.Close()
|
||||
return nil, ctx.Err()
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &connection{handler: handler, client: gos7.NewClient(handler)}, nil
|
||||
}
|
||||
|
||||
type address struct {
|
||||
area string
|
||||
db, offset, bit int
|
||||
}
|
||||
type connection struct {
|
||||
mu sync.Mutex
|
||||
handler *gos7.TCPClientHandler
|
||||
client gos7.Client
|
||||
}
|
||||
|
||||
func intConfig(c map[string]any, k string) int {
|
||||
switch v := c[k].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func parseAddress(raw string) (address, error) {
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return address{}, errors.New("无效S7地址")
|
||||
}
|
||||
a := address{bit: -1}
|
||||
first := parts[0]
|
||||
if strings.HasPrefix(first, "DB") {
|
||||
a.area = "DB"
|
||||
var e error
|
||||
a.db, e = strconv.Atoi(strings.TrimPrefix(first, "DB"))
|
||||
if e != nil || len(parts) < 2 {
|
||||
return a, errors.New("无效DB地址")
|
||||
}
|
||||
a.offset, e = strconv.Atoi(parts[1])
|
||||
if e != nil {
|
||||
return a, e
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
a.bit, e = strconv.Atoi(parts[2])
|
||||
if e != nil {
|
||||
return a, e
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.area = first[:1]
|
||||
offsetText := first[1:]
|
||||
if len(offsetText) > 1 && (offsetText[0] == 'W' || offsetText[0] == 'D') {
|
||||
offsetText = offsetText[1:]
|
||||
}
|
||||
var e error
|
||||
a.offset, e = strconv.Atoi(offsetText)
|
||||
if e != nil {
|
||||
return a, e
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
a.bit, e = strconv.Atoi(parts[1])
|
||||
if e != nil {
|
||||
return a, e
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (c *connection) Read(ctx context.Context, raw string, t protocol.DataType) (float64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
a, e := parseAddress(raw)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
size := 2
|
||||
if t == protocol.Bool {
|
||||
size = 1
|
||||
} else if t == protocol.Real {
|
||||
size = 4
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if e = c.read(a, size, buf); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
switch t {
|
||||
case protocol.Bool:
|
||||
if buf[0]&(1<<a.bit) != 0 {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
case protocol.Int:
|
||||
return float64(int16(binary.BigEndian.Uint16(buf))), nil
|
||||
default:
|
||||
value := float64(math.Float32frombits(binary.BigEndian.Uint32(buf)))
|
||||
return math.Round(value*1000) / 1000, nil
|
||||
}
|
||||
}
|
||||
func (c *connection) Write(ctx context.Context, raw string, t protocol.DataType, v float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
a, e := parseAddress(raw)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if t == protocol.Bool {
|
||||
buf := []byte{0}
|
||||
if e = c.read(a, 1, buf); e != nil {
|
||||
return e
|
||||
}
|
||||
if v != 0 {
|
||||
buf[0] |= 1 << a.bit
|
||||
} else {
|
||||
buf[0] &= ^(1 << a.bit)
|
||||
}
|
||||
return c.write(a, 1, buf)
|
||||
}
|
||||
size := 2
|
||||
buf := make([]byte, 4)
|
||||
if t == protocol.Int {
|
||||
binary.BigEndian.PutUint16(buf, uint16(int16(v)))
|
||||
} else {
|
||||
size = 4
|
||||
binary.BigEndian.PutUint32(buf, math.Float32bits(float32(v)))
|
||||
}
|
||||
return c.write(a, size, buf[:size])
|
||||
}
|
||||
func (c *connection) read(a address, size int, b []byte) error {
|
||||
switch a.area {
|
||||
case "DB":
|
||||
return c.client.AGReadDB(a.db, a.offset, size, b)
|
||||
case "M":
|
||||
return c.client.AGReadMB(a.offset, size, b)
|
||||
case "I":
|
||||
return c.client.AGReadEB(a.offset, size, b)
|
||||
case "Q":
|
||||
return c.client.AGReadAB(a.offset, size, b)
|
||||
}
|
||||
return errors.New("不支持的S7区域")
|
||||
}
|
||||
func (c *connection) write(a address, size int, b []byte) error {
|
||||
switch a.area {
|
||||
case "DB":
|
||||
return c.client.AGWriteDB(a.db, a.offset, size, b)
|
||||
case "M":
|
||||
return c.client.AGWriteMB(a.offset, size, b)
|
||||
case "Q":
|
||||
return c.client.AGWriteAB(a.offset, size, b)
|
||||
}
|
||||
return errors.New("不支持的S7写入区域")
|
||||
}
|
||||
func (c *connection) Close() error { c.mu.Lock(); defer c.mu.Unlock(); return c.handler.Close() }
|
||||
Reference in New Issue
Block a user