在 SagooIoT 里新增一个属性,展开"点位配置"那一栏,你会看到一个随协议变化的面板。
接 Modbus 设备时,它要你填寄存器类型、功能码、寄存器地址、数据类型、读取数量、系数、偏移、字节序、位索引、轮询分组——十个字段。接 IEC 61850 的 IED 时,面板上只剩两个:对象引用,和功能约束。
这不是哪个协议漏做了。这两个协议对"一个点位"的定义本来就不一样。Modbus 的点位是一个裸地址,所有语义都得你在外面补;IEC 61850 的点位本身就是一串自带语义的对象引用,simpleIOGenericIO/GGIO1.AnIn1.mag.f 里已经写明了逻辑设备、逻辑节点、数据对象和属性。
平台要同时伺候这两类东西。做法不是发明一套万能地址格式去覆盖所有方言——那种格式最后一定会变成"大部分字段对大部分协议无意义"。做法是承认差异,把差异收进一个字段里,让每个协议插件自己声明"我这张面板上要填什么"。
这篇文章讲的就是这层骨架:十八个工业协议驱动插件,接口完全一样,面板完全不同,中间那条缝是怎么对上的。
先分清两种插件,它们连"handleType"都不是一个意思
打开任一插件的 info.json,第一行都是 types。这个字段决定了平台把它注册到哪个管理器里:
{
"types": "driver",
"handleType": "modbus",
"name": "modbus",
"title": "Modbus驱动插件",
"command": "./driver-modbus"
}
手头的插件目录里,types 有四种取值:driver 十八个、protocol 五个、notice 七个、ui 一个。后两类上一篇文章讲过通知插件,ui 是给管理端加页面用的,这里都不展开。
关键是 driver 和 protocol 的区别,这两类很容易被混为一谈,因为名字都叫"插件"。
驱动插件(driver)自己管连接。 它按配置去 Dial 设备的 IP:Port 或串口,自己构造请求报文,自己收响应。Modbus 是它去读寄存器,SNMP 是它去发 Get,OPC UA 是它去 Subscribe 或 Read 节点。设备不主动说话,插件按时去问。
协议插件(protocol)不管连接。 它接手的是网络组件已经收上来、已经切好包的一段字节,负责把字节解成物模型属性。LwM2M、海湾 GST-TCP、通用二进制、JSON 协议都属于这一类。
这个分工可以直接从 handleType 上看出来,看这张表:
| 插件 | types | handleType | version |
|---|---|---|---|
| modbus | driver | modbus | 1.0.0 |
| opcua | driver | opcua | 1.0.0 |
| siemens-s7 | driver | siemens-s7 | 1.0.0 |
| iec104 | driver | iec104 | 1.0.0 |
| iec61850 | driver | iec61850 | 0.1.0 |
| snmp | driver | snmp | 1.0.0 |
| bacnet | driver | bacnet | 1.0.0 |
| dlt645-2007 | driver | dlt645-2007 | 1.0.0 |
| cjt188-2004 | driver | cjt188-2004 | 1.0.0 |
| coap | driver | coap | 1.0.0 |
| http-rest | driver | http-rest | 1.0.0 |
| gst-tcp | protocol | tcp_server | 1.0.0 |
| lwm2m | protocol | udpServer | 1.0.0 |
| protocol-binary | protocol | tcpServer | 1.1.0 |
| protocol-json | protocol | json | 1.0.0 |
驱动插件的 handleType 填的是协议名——因为它是按协议实现的。协议插件的 handleType 填的是传输形态——因为它挂在网络组件后面,只关心字节从哪种通道来。
顺着这张表还能看出两件不那么整齐的事。
第一,version 是有信息的。IEC 61850 是 0.1.0,其余驱动都是 1.0.0,通用二进制是 1.1.0。版本号不是摆设,0.1.0 对应的是一个明确的 MVP 范围,后文会讲。
第二,handleType 的取值集合本身在漂。开源仓库里那个枚举文件定义了十个驼峰值:
const (
TcpServer = "tcpServer"
TcpClient = "tcpClient"
UdpServer = "udpServer"
UdpClient = "udpClient"
HttpServer = "httpServer"
HttpClient = "httpClient"
MqttServer = "mqttServer"
MqttClient = "mqttClient"
WebSocketServer = "webSocketServer"
WebSocketClient = "webSocketClient"
)
对照上表:tcp_server 用了下划线,tcpServer 用了驼峰,json 既不是传输也不是端口形态。同一个小字段里三种写法。不致命——驱动侧根本不用它做分支判断——但它说明这类"元信息字段"很容易在缺少校验的情况下各自演化。
十八个驱动,开发者只需要实现五个方法
驱动插件这一侧,接口是统一的。pkg/plugin-sdk/driver/interfaces.go 里定义了一个开发者必须实现的接口:
// ProtocolHandler 协议业务逻辑接口
// 开发者只需要实现此接口即可完成驱动插件开发
type ProtocolHandler interface {
// Connect 建立设备连接
Connect(config ConnectConfig) (interface{}, error)
// Read 执行数据采集
Read(client interface{}, points []Point) (map[string]interface{}, error)
// Write 执行数据写入/控制
Write(client interface{}, req WriteRequest) error
// Disconnect 断开连接(可选)
Disconnect(client interface{}) error
// GetTSLConfigMeta 获取点位配置元数据 (用于前端动态渲染配置表单)
GetTSLConfigMeta() []map[string]interface{}
}
五个方法。翻一遍十八个驱动的 handler.go,方法签名逐字一致——ModbusHandler、OpcUaHandler、SNMPHandler、IEC104Handler、Iec61850Handler、DLT645Handler,全都在同一组签名下实现业务。
但 SDK 暴露给 RPC 层的接口是十个方法:
type Driver interface {
Info() common.PluginInfo
Connect(config map[string]string) common.JsonRes
Read(deviceKey string) common.JsonRes
// ReadGroup 按 pollGroup 过滤后读取;未单独声明时 RPC 层回退 Read。
ReadGroup(deviceKey, group string) common.JsonRes
Write(deviceKey string, data []byte) common.JsonRes
Disconnect(deviceKey string) common.JsonRes
GetStatus(deviceKey string) common.JsonRes
GetTSLConfigMeta() common.JsonRes
ValidatePointConfig(cfg map[string]interface{}) common.JsonRes
ValidatePointTable(points []PointTableItem) common.JsonRes
}
多出来的五个不需要每个驱动自己写。BaseDriver 已经实现好了,它负责把 RPC 请求翻译成对 Handler 的调用,外加连接上下文管理、参数解析和 panic 兜底:
func (b *BaseDriver) Connect(rawConfig map[string]string) (res common.JsonRes) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "Panic in Connect: %v\nStack: %s", r, debug.Stack())
res = common.Error(fmt.Errorf("panic in Connect: %v", r))
}
}()
...
}
这段 recover 值得提一句。驱动插件跑在独立进程里,一个没防住的 panic 会把插件进程打死;包一层 recover 至少能让主进程收到一条错误而不是连接断开。不过它只能拦住 Connect 内的 panic——Read 和 Write 里的越界仍然会掀掉进程,这一点后面排错那节还要回来。
另外两个可选接口
除了必须实现的五个方法,SDK 还留了两个可选的:
// PointValidator 点位配置可选校验接口。
// Handler 实现后,平台保存物模型属性时会对 extConfig.config 做字段级强校验,
// 作为弱校验 Validate(仅查 protocol 非空)的后端兜底;未实现则跳过,保持向后兼容。
type PointValidator interface {
ValidatePointConfig(cfg map[string]interface{}) []string
}
// PointTableValidator 跨属性点表可选校验接口。
// Handler 实现后,平台在新增/编辑/批量保存属性时传入同协议全部点位做重叠/互斥检查。
type PointTableValidator interface {
ValidatePointTable(points []PointTableItem) []string
}
这两个接口的设计取向很明确:平台只管"这个是 Modbus 协议的配置",字段级合不合法交还给协议自己判断。 注释里那句话写得很直白——平台侧弱校验只查 protocol 非空。
Modbus 就把这个接口用得很足,ValidatePointConfig 里逐字段给出了人话错误:
if v, ok := cfg["registerAddr"]; !ok || v == nil {
add("modbus.registerAddr 字段不能为空")
} else if n, ok := common.ToIntStrict(v); !okNum {
add("modbus.registerAddr 类型无效: %v(仅接受数字或纯数字字符串)", v)
} else if n < 0 || n > 65535 {
add("modbus.registerAddr 取值范围为0-65535, 当前值: %d", n)
}
IEC 61850 的做法更省事——它把校验直接复用运行时的解析器,保证"校验能过"和"采集能跑"用的是同一套判定:
// 复用运行时解析器 pointAddress + parseFC(对象引用必填、FC 枚举),保证校验与采集行为一致。
func (h *Iec61850Handler) ValidatePointConfig(cfg map[string]interface{}) []string {
_, fcStr, err := pointAddress(cfg)
if err != nil {
return []string{err.Error()}
}
if _, err := parseFC(fcStr); err != nil {
return []string{err.Error()}
}
return nil
}
这是这套可选接口最该有的用法。校验写一套、运行写另一套,最后一定会分叉。
面板上要填什么,由插件自己声明
现在说回开头那张会变的面板。它不是一个写死的前端表单,而是插件通过 GetTSLConfigMeta 吐出来的一份字段描述,前端照着渲染。
Modbus 吐十个字段:
func (h *ModbusHandler) GetTSLConfigMeta() []map[string]interface{} {
return []map[string]interface{}{
{
"type": "select",
"field": "registerType",
"title": "寄存器类型",
"value": "holding",
"options": []map[string]string{
{"label": "线圈寄存器 (Coil)", "value": "coil"},
{"label": "离散输入寄存器 (Discrete)", "value": "discrete"},
{"label": "保持寄存器 (Holding)", "value": "holding"},
{"label": "输入寄存器 (Input)", "value": "input"},
{"label": "自定义功能码 (Custom)", "value": "custom"},
},
...
},
// 还有 function / registerAddr / dataType / quantity / dataCoef
// / offset / byteOrder / bitIndex / pollGroup
}
}
IEC 61850 吐两个:
func (h *Iec61850Handler) GetTSLConfigMeta() []map[string]interface{} {
return []map[string]interface{}{
{
"type": "input",
"field": "address",
"title": "对象引用 (Object Reference)",
"validate": []map[string]interface{}{
{"type": "string", "required": true, "message": "请输入 IEC 61850 对象引用"},
},
"tips": "格式: LD/LN.DO.DA,例如 simpleIOGenericIO/GGIO1.Ind1.stVal",
},
{
"type": "select",
"field": "fc",
"title": "功能约束 (FC)",
"value": "ST",
"options": []map[string]interface{}{
{"label": "ST - 状态", "value": "ST"},
{"label": "MX - 测量", "value": "MX"},
{"label": "SP - 设定", "value": "SP"},
// ...
},
"tips": "对应 MMS 功能约束,测量量通常用 MX,状态量用 ST,设定用 SP",
},
}
}
把两者放在一起看,规律很清楚:协议越"裸",面板越复杂;协议越"重",面板越简单。
Modbus 的 registerAddr 是一个 0–65535 的整数,它不告诉你这个地址上是什么、几个字节、什么字节序、怎么换算。这些全得在面板上补,所以十个字段里有八个是在补语义。
IEC 61850 的对象引用自带完整层级,剩下的唯一变量是"从哪个功能约束去读",所以两个字段就够了——第二个字段还是可选项。同一个 tips 里连"测量量通常用 MX"这种经验判断都写进去了,因为这个地方填错的概率实在太高。
那个能把所有方言都装下的字段
面板上填完,值存到哪儿?
物模型属性上有一个 extConfig 字段,里面装着 protocol 和 config 两部分。protocol 说明这份配置属于哪个协议,config 是面板上那一堆字段的值。
驱动侧解析物模型时,把它转成一个跨协议的 Point 结构:
// Point 采集点位定义
type Point struct {
Name string `json:"name"` // 点位标识/属性名
Address uint16 `json:"address"` // 寄存器/点位地址
Function byte `json:"function"` // 功能码 (如有)
Type string `json:"type"` // 数据类型 (int16, float32, bool 等)
Scale float64 `json:"scale"` // 缩放系数
Offset float64 `json:"offset"` // 偏移量
Description string `json:"description"` // 描述
Config map[string]interface{} `json:"config,omitempty"` // 扩展配置(JSON中名为extConfig)
}
这个结构体里有意思的是 Address 和 Function 这两个字段。
Address 是 uint16,Function 是 byte。这两个类型不是随便选的——它们正好是 Modbus 的地址宽度和功能码宽度。换句话说,这个"跨协议通用结构体"里,有两个字段是照着 Modbus 的尺寸做的。
问题在于,别的协议用不上。SNMP 的地址是一串 OID 字符串,IEC 61850 的地址是对象引用,DL/T 645 的地址是 8 位十六进制数据标识——uint16 装不下任何一个。
所以真正干活的是 Config。看一眼解析逻辑就明白了:
// parseTSL 将物模型属性转换为点位 Point。
// 协议专用字段(寄存器地址、功能码等)只保留在 Config,由 Handler 自取;
// Type / Scale / Offset 作为跨协议通用字段继续预填。
func parseTSL(tslJSON string, protocolTypes ...string) ([]Point, error) {
...
point := Point{
Name: prop.Key,
Description: prop.Name,
Config: cfg, // 完整透传,Handler 读取协议字段
}
if scale, ok := getFloat(cfg, "scale", "dataCoef"); ok {
point.Scale = scale
}
if offset, ok := getFloat(cfg, "offset"); ok {
point.Offset = offset
}
// Type 保留作通用字段(modbus quantity、各驱动 dataType 共用)
if dataType, ok := getString(cfg, "dataType", "type"); ok {
point.Type = dataType
}
points = append(points, point)
...
}
extConfig.config 整包透传进 Point.Config,然后只从里面挑三样东西预填到通用字段上:scale/dataCoef、offset、dataType。其余一律不动,留给 Handler 自己从 Config 里取。
这三个被挑出来的字段也确实是最通用的:系数和偏移是几乎所有模拟量都要的换算,数据类型是几乎每个协议都要声明的。至于"地址",各家有各家的写法,只能各取各的。
SDK 文档里把这条约定写成了硬规则:
Point.Address/Point.Function不再由 SDK 预填(Modbus 专用)。Handler 应从Config["registerAddr"]等自行读取。
写路径上也是同一个思路。平台下发指令时只给 {key, value},SDK 按 key 找到对应的 Point,把它的 Config 整个拷进写请求:
// Smart Write: If Key is provided, lookup Point info
if req.Key != "" {
for _, p := range clientWrap.Config.Points {
if p.Name == req.Key {
// 协议字段(寄存器地址/功能码等)一律走 Config:parseTSL 不再预填
// Point.Address/Function,Handler 自行从 Config 读取。
if req.Config == nil {
req.Config = make(map[string]interface{})
}
for k, v := range p.Config {
req.Config[k] = v
}
// Inject standard Type if not present in Config
if _, ok := req.Config["dataType"]; !ok {
req.Config["dataType"] = p.Type
}
break
}
}
}
所以现场配一次点位,读和写走的是同一份协议参数。这一点很重要——Modbus 的字节序如果读的时候是 CDAB,写的时候也得是 CDAB,否则写进去的浮点数会被拆成两个反向的寄存器。
一个不留日志的坑:protocol 对不上,点位就没了
parseTSL 里还有一处过滤,值得单独拎出来说,因为它造成的现象非常像"设备没数据"。
allowed := make(map[string]struct{}, len(protocolTypes))
for _, p := range protocolTypes {
if p != "" {
allowed[p] = struct{}{}
}
}
var points []Point
for _, prop := range tsl.Properties {
// HandleType 与物模型 protocol 可能不同(http-rest vs http),TSLProtocol/Name 一并纳入
if proto := prop.ExtConfig.Protocol; proto != "" {
if _, ok := allowed[proto]; !ok {
continue
}
}
...
}
判定逻辑是两条:
- 属性上的
extConfig.protocol如果是空的,这个点位无条件保留。这条是兼容老配置的。 - 如果写了值,它必须落进
allowed集合里,否则continue——这个点位直接被跳过,没有任何日志。
allowed 集合由三个字段拼成:插件的 HandleType、Name、Capabilities.TSLProtocol。
对 Modbus 来说这三个都是 modbus,怎么写都对。但对 HTTP REST 驱动就不一样了:
{Name: "http-rest", HandleType: "http-rest", Capabilities: model.DriverCapabilities{TSLProtocol: "http"}},
插件名叫 http-rest,但物模型属性上该写的是 http。写得不一样,这个点位就被静默丢掉了——GetTSLConfigMeta 吐出来的面板照常渲染,配置照常保存成功,校验也照常通过,然后采集回来的数据里就是没有这一项。
这类问题的麻烦之处在于它不报错。真出问题时,最快的定位办法是打开属性配置看一眼 extConfig.protocol 的值,再对一下插件的 name、handleType,以及它有没有声明 tslProtocol。
"同一台设备"由插件自己定义
驱动插件是主动去问设备的。同一台 PLC 上挂十几个属性、轮询周期又短的时候,平台必须保证同一时刻只有一个请求在飞——Modbus TCP 的一个连接上并发发两条读命令,响应会串在一起。
平台的解法是按"连接"加锁。但"什么算同一个连接"这件事,各家协议不一样,所以锁键也交给插件声明:
// DriverCapabilities 驱动运行时能力声明。
type DriverCapabilities struct {
// WriteMode 属性写入模式:smart_key(默认,仅接受 {key,value})
WriteMode string `json:"writeMode,omitempty"`
// CollectionMode 采集模式:poll / push / subscribe
CollectionMode string `json:"collectionMode,omitempty"`
// LockKeyFields 用于推导并发锁的配置字段(有序)
LockKeyFields []LockKeyField `json:"lockKeyFields,omitempty"`
// Transports 支持的传输模式:direct / tcp_server_tunnel
Transports []string `json:"transports,omitempty"`
// TSLProtocol 物模型 extConfig.protocol 取值,默认等于 HandleType
TSLProtocol string `json:"tslProtocol,omitempty"`
}
// LockKeyField 并发锁键的配置字段声明。
type LockKeyField struct {
Field string `json:"field"` // 配置字段名
Normalize string `json:"normalize,omitempty"` // "" / "lower" / "trim_slash"
Default string `json:"default,omitempty"` // 值为空时的缺省
Fallback string `json:"fallback,omitempty"` // 值仍为空时回退读取的字段
}
LockKeyField 这四个子字段是有讲究的,它们解决的是"同一台设备被拆成两把锁"的问题。看三个协议的实际声明:
// modbus
LockKeyFields: []common.LockKeyField{
{Field: "mode", Normalize: "lower", Default: "tcp"},
{Field: "address", Fallback: "deviceKey"},
// 不含 baudRate/dataBits/stopBits/parity:锁键无法按 mode 条件取舍,
// 带上串口参数会让 TCP 锁等价类偏离 HEAD;RTU 同端口合锁更安全。
},
Transports: []string{"direct", "tcp_server_tunnel"},
// opcua
LockKeyFields: []modelPlugin.LockKeyField{
{Field: "endpoint", Normalize: "trim_slash"},
},
// siemens-s7
LockKeyFields: []modelPlugin.LockKeyField{
{Field: "host"},
{Field: "port", Default: "102"},
{Field: "rack", Default: "0"},
{Field: "slot"},
},
逐条看:
Modbus 的锁键是 modbus:mode:address。mode 用 lower 归一,是因为现场有人写 TCP 有人写 tcp,不归一就是两把锁。address 空了回退 deviceKey,因为串口模式下 address 就填的是设备路径,可能留空。刻意不带串口参数——理由写在注释里:锁键没法按 mode 条件取舍字段,TCP 配置里如果残留了一组串口参数,键就变了,同一台设备会被拆成两把锁。宁可让 RTU 下同端口的不同参数合到一把锁上(同一根物理串口本来就必须串行),也不能让 TCP 裂开。
OPC UA 的锁键是 endpoint,trim_slash 把末尾斜杠去掉。opc.tcp://a:4840 和 opc.tcp://a:4840/ 是同一个端点,不归一就是两把锁。
S7 最容易被忽略的是 port 和 rack 的 Default。现场配置里 port 不填是 102、rack 不填是 0,但如果锁键不归一,不填和显式填就是两个键——同一台 PLC 的两把锁,读写失去互斥。
平台侧还有一张内置兜底表,在插件管理器没就绪时按插件名返回同样的字段。表上面的注释把风险说清楚了:
注意:builtin 与插件 main.go 声明必须逐字一致,否则 Caps 从不可用翻转为可用时,同一物理连接会被拆成多把锁(读写互斥失效)。
顺带一提 Transports。十八个驱动里只有 Modbus 声明了 tcp_server_tunnel,其余都是 ["direct"]。隧道模式是指设备反向拨号连到平台的 TCP 服务,平台再把这根连接交给驱动去读写——适合设备在 NAT 后面、平台拨不进去的场景。内置兜底表也只覆盖 Modbus 这一个。
采集周期不是一个数
紧接着锁的另一个设计是分组采集。
一台设备上的点位,重要性往往不一样。温度值可能 5 秒要一次,累计电量一小时一次就够。如果整张表按同一个周期跑,快的点位被拖慢,慢的点位白白占带宽。
SagooIoT 的做法是给每个点配一个 pollGroup:
{
"type": "select",
"field": "pollGroup",
"title": "轮询分组",
"options": []map[string]string{
{"label": "默认", "value": ""},
{"label": "快组", "value": "fast"},
{"label": "慢组", "value": "slow"},
},
"props": map[string]interface{}{
"placeholder": "可选;调度分组由平台侧消费",
},
}
平台侧按设备连接配置推导出每个组的周期:
intervals := map[string]time.Duration{
"default": defaultInterval,
"fast": defaultInterval,
"slow": defaultInterval * 6,
}
if cfg != nil {
if v := strings.TrimSpace(cfg["pollIntervalFast"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
intervals["fast"] = time.Duration(n) * time.Second
}
}
if v := strings.TrimSpace(cfg["pollIntervalSlow"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
intervals["slow"] = time.Duration(n) * time.Second
}
}
if raw := strings.TrimSpace(cfg["pollGroupIntervals"]); raw != "" {
var m map[string]int
if json.Unmarshal([]byte(raw), &m) == nil {
for k, sec := range m {
if sec > 0 {
intervals[strings.ToLower(strings.TrimSpace(k))] = time.Duration(sec) * time.Second
}
}
}
}
}
周期有三个来源,优先级从低到高:默认值 → pollIntervalFast / pollIntervalSlow → pollGroupIntervals 这个 JSON 一次性覆盖。慢组的默认值是默认组的 6 倍,这个倍数没写在面板上,只在代码里。
然后有一条规则必须知道:
if !seenNamed {
// 全部点未分组 → 不启用分组调度
return pollGroupPlan{}
}
只要所有点位都没有配命名分组,整套分组调度就不启用。 所有点跟着同一个周期走,和以前的行为一致。这解释了为什么"我在面板上留空"和"我以为它会自动分快慢"是两回事。
分组生效之后,读取请求会带组名去过滤点位:
// ReadGroup 按 pollGroup 过滤点位后读取。
// group 为空:读全部点(兼容旧行为)。
// group 非空:只读 Config["pollGroup"] 匹配的点;无匹配点时返回空 properties。
func (b *BaseDriver) ReadGroup(deviceKey, group string) common.JsonRes {
...
points := clientWrap.Config.Points
if group != "" {
points = filterPointsByPollGroup(points, group)
}
...
}
// filterPointsByPollGroup 保留 pollGroup 等于 group 的点;
// group 为 "_" 或 "default" 时匹配未配置 / 空 pollGroup 的点。
注意 "_" 和 "default" 两个取值都指向"未分组的点"。前者是调度器内部的占位键,后者是面板上那个空选项的落点。
还有一处细节在任务创建时:
plan := buildPollGroupPlan(config, interval)
if plan.active() {
if plan.min > 0 && (interval <= 0 || plan.min < interval) {
t.Interval = plan.min
}
}
设备级任务的心跳被压到所有分组里最短的那个周期。这样才能保证每个组到期时都有人来检查,不会出现"整台设备的 tick 比快组还慢"。
Modbus 为什么比别人大一号
把十八个驱动的代码量排一下,第一名和第二名之间有明显的台阶:
| 插件 | Go 代码行数 |
|---|---|
| modbus | ~3315 |
| http-rest | ~1224 |
| iec104 | ~854 |
| snmp | ~837 |
| siemens-s7 | ~831 |
| cjt188-2004 | ~813 |
| dlt645-2007 | ~752 |
| bacnet | ~652 |
| iec61850 | ~636 |
| opcua | ~629 |
| coap | ~541 |
Modbus 比第二名多出将近两倍。多出来的部分不在协议解析——Modbus 报文本身很简单,MBAP 头 7 字节加 PDU,比 IEC 104 的 ASDU 结构好学多了——而在寄存器批量合并。
Modbus 协议允许一次性读一段连续寄存器,但有数量上限(功能码 3/4 常见是 125 个寄存器)。如果一张表上有 200 个分散点位,逐点读就是 200 次事务;先合并再读,可能只要三五次。现场一个 PLC 上挂几百个点位是常事,这个差距直接决定了采集周期能不能跑进 SLA。
合并逻辑不长,核心是一个贪心扫:
func mergeBlocks(points []resolvedPoint, maxGap, maxQuantity uint16) []readBlock {
...
// next.start <= cur.end+1+maxGap ⟺ gap <= maxGap
if gap <= int(maxGap) && newQty > 0 && newQty <= int(maxQuantity) {
// 扩入当前块
}
// 否则开启新块
...
}
规则拆开讲:
分组。 先按 (功能码, 从站ID) 分组。功能码不同不能合并——读保持寄存器和读线圈是两个完全不同的请求。从站 ID 不同也不能——同一个 IP 后面的 1 号和 2 号从站是两个独立设备。
排序。 组内按地址升序排,宽度由数据类型决定:uint16 占 1 个寄存器,float32/int32 占 2 个。合并时用宽度算块尾,保证不会把 float32 从中间切开。
合并条件。 下一个点的起始地址离当前块尾不超过 maxGap 就并进来,同时块总长不能超过 maxQuantity。两个参数都有默认值:maxGap 默认 16,maxQuantity 默认 125(线圈另有一档更小的上限)。
maxGap 是个纯工程权衡。两块地址之间隔了几个寄存器,值不值得把它们一起读?读回来的空洞寄存器要丢掉,但多读十几个寄存器几乎不增加耗时——省下的是整整一次往返。间隙再大就该新开块了,不然读一堆无关数据反而更慢。
失败策略。 这里的选择是"整块失败":
out := make(map[string]interface{}, len(points))
for _, block := range mergeBlocks(points, maxGap, maxQuantity) {
raw, err := readBlock(client, block)
if err != nil {
return nil, err // 与现网失败语义对齐
}
decodeBlockInto(out, block, raw)
}
块里只要有一个点读失败,整个块返回错误,这一轮所有点都没有值。不做部分成功,是因为部分成功会造成"一半点位有数、一半没数、看起来像设备故障"的假象,而且失败的那几个点往往正是问题所在。排障时如果怀疑是合并导致的,把 collectionMode 改成 single 就能退回逐点读,逐个点定位。
maxGap 的副作用也值得记一笔:空洞地址被拒答时会连带整块失败。有些老旧从站对不连续的读请求直接返回异常码,这时候要么把 maxGap 调小,要么退回逐点。
顺带说一个同名字段
Modbus 的设备连接配置里有一个 collectionMode,取值 single / batch,默认 batch,管的是"要不要合并寄存器读"。
而 SDK 的能力声明里也有一个 CollectionMode,注释写着:
// CollectionMode 采集模式:poll / push / subscribe
CollectionMode string `json:"collectionMode,omitempty"`
同名、不同层、值域完全不相交。前者是设备级配置,后者是插件级能力声明;前者决定"怎么读",后者决定"谁发起读"。
两个都叫 collectionMode 的地方,在排错时很容易拿错。看到这个键先确认自己在哪一层:在 Capabilities 里它就是 poll,在设备连接配置里它就是 batch 或 single。
每种协议能做什么、不能做什么
前面讲的都是结构。这一节讲边界,因为接入项目里最贵的误解往往不是"这个功能怎么用",而是"这个功能到底有没有"。
按插件逐个列一遍官方文档和插件元信息里明写的限制:
IEC 61850 —— 版本号 0.1.0,描述里直接写清了范围:
基于 MMS 的 IEC 61850 客户端驱动(libIEC61850/cgo),支持按对象引用读写。MVP:Connect/Read/Write,不含 Report/GOOSE/SV
文档里还有一条更重要的提示:
底层库为 GPL-3.0,商用分发需自行评估合规。编译依赖 CGO。
这两句话在一起说明了一件事:如果你的场景依赖 Report 主动上报、或者依赖 GOOSE 做快速跳闸,这个插件现在不覆盖;如果要商用分发,GPL-3.0 传染性得自己评估;CGO 也意味着交叉编译不能像其他插件那样 CGO_ENABLED=0 一把过。
LwM2M —— 明确不支持下行:
将网络组件 LwM2M 服务端上报的信封解码为物模型属性 JSON。MVP 不支持 Encode(平台主动 Read/Write/Execute)
所以它是只读的。平台不能主动去 Read 一个 LwM2M 设备的资源,只能等设备上报。
IEC 104 —— 取数方式是个特例:
支持电力"四遥"。连接后会按公共地址自动总召;上行数据被动接收并缓存,读点时从缓存取值。
这是唯一一个"以推为主、以拉为辅"的驱动。它连上就自动发总召唤,之后设备按变化上送,插件把值缓存在内存里,平台来读时直接从缓存返回——缓存键是 ASDU:IOA 拼接出来的。这个设计对电力场景是对的(遥信变位必须实时上送),但它带来一个后果:缓存里的值是"最后一次上送的值",不是"当前时刻去问来的值"。 如果设备长时间没变化,你读到的会是一个时间戳很旧的值。
下发侧还有一张明确的能力表,其中最需要记住的是这一条:
参数类 TypeID 110–113 当前不支持下发。
SNMP —— 文档在点位的 operation 字段上留了一句:
get/getnext/walk(实际以 Get 为主)
这句话很轻,但意思是:别把 walk 当成生产可用的能力去设计方案。
OPC UA —— 一个高频坑:
OPC UA 对类型校验严格。设备点为 Int16 时物模型需选整型;误选 float 可能导致
BadTypeMismatch。
OPC UA 的类型系统是强类型的,和 Modbus 那种"你说是啥就是啥"完全相反。这一条踩过一次就不会忘。
写入语义,各家也不同。 同样叫"下发控制",落到不同协议上是四件不同的事:
| 协议 | 写入约束 |
|---|---|
| Modbus | 按点位的地址与功能码写;Float32/Int32 按字节序拆成两个 16 位寄存器 |
| OPC UA | 按 NodeID 写,插件按 dataType 自动转换类型 |
| SNMP | 需点位显式开启 writable 才走 Set |
| BACnet | 按对象类型自动转 REAL/ENUMERATED,写优先级 8 |
| DL/T 645 | 写数据、写地址、冻结命令需配合密码与操作者代码 |
| CJ/T 188 | 支持开阀/关阀与写地址 |
| 海湾 GST-TCP | 下行当前为透传 |
| 通用二进制 | 帧头或校验失败则整帧失败;单个字段越界或类型失败则跳过该字段 |
BACnet 那个"优先级 8"值得留意——楼宇自控里 Present_Value 是一个带优先级的数组,写进去的值要服从优先级仲裁。你在平台上点一下开关,实际是往优先级 8 里写了一个值,如果现场有个更高优先级的联动逻辑在跑,这个值会被盖掉。这不是插件的问题,是 BACnet 的语义,配置的人得知道。
最后一条最容易被低估:文档覆盖面和插件实际数量不是一回事。 官方插件手册里列了 15 个(11 个驱动 + 4 个协议),而插件目录里有 18 个驱动和 5 个协议。差额那部分是 EtherNet/IP、HART-IP、KNX、三菱 SLMP、欧姆龙 FINS、Mettler-Toledo、MTConnect、GB/T 32960 这些——它们有实现、有方案文档,但没进公开的插件手册。选型时别只照着手册翻,以插件清单为准。
出不来数据时,按这个顺序查
把上面这些拼成一条排错路径:
第一步,看点位有没有被过滤掉。 打开出不来数的那个属性,检查 extConfig.protocol。如果它非空,且不等于插件的 name / handleType / tslProtocol 中的任何一个,这个点位从一开始就没进采集列表,且不会有任何日志。这是最容易误判成"设备故障"的一种。
第二步,看面板字段是不是照插件声明填的。 Modbus 的 ValidatePointConfig、IEC 61850 的地址解析,都会给出人话错误。保存时如果没被拦住,说明可能压根没走这个校验(比如插件还没就绪),那就手工对一遍 GetTSLConfigMeta 里的字段名。
第三步,看锁键归一了没有。 读写会不会互相打断,取决于"同一台设备"的判定。写 TCP 和 tcp、opc.tcp://a:4840 和 opc.tcp://a:4840/、port 留空和填 102——这几组如果不归一,同一台设备会有两把锁,而现象是"偶发数据错乱",不会报错。
第四步,看采集分组生效了没有。 如果所有点位都没配命名分组,整张表按设备级周期跑。你以为快组在 5 秒跑,实际还跟着默认周期。
第五步,看插件的 Info() 返回了什么。 这一条有个专门的实现细节:
info := d.Info()
if info.HandleType == "" {
// Info RPC 失败时返回零值(见 DriverRPC.Info):不缓存,避免一次瞬时失败
// 把 Caps 永久固化为零值(锁粒度退化、Transports 不可见且无任何日志)。
return modelPlugin.DriverCapabilities{}, false
}
pluginCapsCache.Store(pluginName, info.Capabilities)
Info() 是一次跨进程 RPC,所以结果被缓存了——不缓存的话,每台设备每个采集周期都要向插件进程发一次请求。而 HandleType 为空被当作"RPC 失败"的信号,此时不写缓存,下次重试。
这个"失败不缓存"的写法值得学。如果反着写——不管结果如何都缓存——一次网络抖动就会把能力声明永久固化为零值,锁粒度退化、隧道模式不可见,而且全程没有任何日志。注释里把这个后果写出来了,说明这是踩过坑之后的修正。
如果插件进程被 kill -9 过、或者二进制被替换过,缓存会在插件失效钩子里清掉,下次读取会重新拉。排错时如果怀疑读到的是旧能力声明,重启插件比重启平台更快。
什么时候该写驱动插件,什么时候不该
回到选型。
该写的情况只有一种:协议是设备厂家的私有协议,没有现成实现,而且短期不会变成标准。 这类场景下写驱动插件很划算——一个独立进程,一份 info.json,实现五个方法,不动平台代码。
不该写的情况是绝大多数:
- 协议是标准的,先翻插件清单。 Modbus、OPC UA、SNMP、BACnet、S7、IEC 104、IEC 61850、DL/T 645、CJ/T 188、CoAP、EtherNet/IP、HART-IP、KNX、三菱、欧姆龙、Mettler-Toledo、MTConnect,常见工业协议基本都在里面了。能复用就别新写。
- 设备只要能上报,就不用驱动。 如果设备自己能主动发数据(MQTT、HTTP 上报),走协议插件或者直接走平台默认的 JSON 协议,比写一个轮询驱动省事得多,也不用操心采集周期和并发锁。
- 只是为了改一个换算公式,不该写插件。
scale/offset是所有驱动都支持的通用字段,配一下就行。
还有两个实际成本要提前说清楚:
协议驱动是主动轮询的,它的开销随点位数线性增长。 一百个点位、10 秒周期,就是每秒十次事务。Modbus 靠寄存器合并把这块压下来,别的协议没这个优化。设计采集方案时,点位数和周期要一起算。
驱动插件崩了不会自动重启。 进程隔离的另一面:插件挂了就是挂了。SDK 给 Connect 包了 recover,但 Read、Write 里的 panic 仍然会掀掉进程。生产环境需要外部手段把插件拉起来。
项目地址:
- GitHub:https://github.com/sagoo-cloud
- 官方文档:https://iotdoc.sagoo.cn
505



被折叠的 条评论
为什么被折叠?



