设备报文怎么变,SagooIoT 只约定三个函数名

接入清单摊开的时候,差别往往不在协议栈上,而在报文长得什么样。同样是"上报一次温湿度",三台设备可能长成这样:

复制代码
{"seq": 8812, "t": 235, "h": 611, "on": 1}

{"type": "report", "ts": 1729734000, "body": {"temp": "23.5", "humi": "61.1"}}

{"d": [{"k": "T", "v": 23.5}, {"k": "H", "v": 61.1}], "ver": 2}

字段名不同、数值量纲不同、甚至结构本身不同。但它们都是合法的 JSON,都能被同一个 MQTT Broker 收下来,也都能在十分钟内接进 SagooIoT ------ 前提是不去改 Go 代码。

这篇文章讲的就是这层"不改代码"的机制:SagooIoT 在产品上留了一个字段叫 script_info,里面放一段 JavaScript,平台只跟这段脚本约定三个函数名。剩下的翻译工作,全在脚本里完成。

一、把翻译层挪出编译期

传统做法是把设备差异写成驱动:一个厂家一个包,字段映射写死,改一次要重新编译、重新打包、重新发版。这套流程在设备型号稳定的场景里没问题,但在项目现场往往会退化成一个很别扭的循环:现场发现某台设备的量纲是 0.1 度、某台多了个状态位、某台把温度放在数组里,每一条都要回到研发排期。

SagooIoT 的选择是把这层翻译从代码里挪出来,挪到产品的配置里。它不是一个"高级选项",而是接在数据主链路上的一段固定工序:

  • 产品实体上有一个 scriptInfo 字段(数据库列 script_info,MySQL 里是 text + utf8mb4);
  • 有脚本就用,没有脚本就跳过,两条路互不影响;
  • 脚本的输入是 JSON 文本,输出也是 JSON 文本,中间是纯 JS。

二、三个函数名,一份最小契约

pkg/jsinterpreter 这个包只有七十多行,但它定义了整套约定中最关键的部分------名字:

复制代码
const (
	FunctionNameByParse = "parse" // 数据接收处理解析函数名
	FunctionNameBySend  = "send"  // 数据下发处理函数名
	FunctionNameByReply = "reply" // 数据回复处理函数名
)

三个常量对应三个方向:

函数名 触发时机 输入 输出
parse 设备上报数据(属性上报、网关批量上报) 设备侧 JSON 平台标准物模型报文
send 平台向设备下发(服务调用等) 平台标准报文 设备能听懂的报文
reply 设备回复平台的方法调用 设备侧回复 JSON 平台标准回复报文

也就是说,接入一台新设备,脚本作者要回答三个问题:收到的东西怎么变成平台认识的、平台发出去的东西怎么变成设备认识的、设备回的话怎么翻译回来。三个函数名一写,剩下的自由发挥。

三、第四个名字,是拼出来的

事件是物模型里比较特殊的一类:一个产品可以定义很多事件(告警、故障、上线通知......),每个事件都可能有自己的回复报文。如果也占用一个固定的函数名,就得在脚本里写一串 switch 分发。

SagooIoT 的处理方式是让函数名带后缀。事件上报走 thing.event.<eventKey>.post,如果要自定义这个事件的回复,就在脚本里写一个 event_reply_<eventKey>:

复制代码
// 高温告警事件的回复
function event_reply_high_temp(data) {
  return {
    code: 200,
    id: data.id,
    message: "received",
    method: "thing.event.high_temp.post_reply",
    version: "1.0"
  };
}

平台侧是这样用的:

复制代码
checkEventReply, err := jsinterpreter.HasMethod(
	data.DeviceDetail.Product.ScriptInfo,
	"event_reply_"+eventKey,
)
if checkEventReply {
	// 走脚本自定义的回复报文
}

注意这里先调用的是 HasMethod 而不是直接执行。HasMethod 会把脚本跑一遍,然后 vm.Get(methodName) 看这个位置是不是一个函数:

复制代码
func HasMethod(jsCode, methodName string) (bool, error) {
	vm := otto.New()
	_, err := vm.Run(jsCode)
	if err != nil {
		return false, fmt.Errorf("error running JavaScript: %v", err)
	}
	value, err := vm.Get(methodName)
	if err != nil {
		return false, fmt.Errorf("error getting method: %v", err)
	}
	return value.IsFunction(), nil
}

有就调用,没有就回平台标准的 ReportEventReply。这个"先探测后调用"的顺序很关键:它让事件回复变成一个可选增强项 ------ 同一个脚本里,你只为需要特殊回复的事件写函数,其余事件自动走标准回执,不需要写一个"空实现"占位。

四、调哪个函数,由消息类型决定

脚本作者不声明"这段脚本是给什么消息用的",声明权在平台侧。MQTT 直连链路里,processMessage 拿到消息后先看 logType:

复制代码
if deviceInfo.Product.ScriptInfo != "" {
	functionName := ""
	switch logType {
	case consts.MsgTypePropertyReport, consts.MsgTypeGatewayBatch:
		functionName = jsinterpreter.FunctionNameByParse
	case consts.MsgTypeFunctionReply:
		functionName = jsinterpreter.FunctionNameByReply
	}
	if functionName != "" {
		jsonData, err = jsinterpreter.RunScriptWithData(functionName, deviceInfo.Product.ScriptInfo, jsonData)
		if err != nil {
			return string(payload), err
		}
	}
}

这段 switch 有两个设计意图。

一是同一个脚本服务多条链路 。属性上报用 parse、方法回复用 reply,都从同一份 script_info 里取函数,产品上不需要挂多份脚本,也不用担心两份脚本版本不一致。

二是语义由主题决定,不由脚本自述 。消息是属性上报还是方法回复,在 MQTT 主题解析阶段就已经确定了(logType 是订阅时注册进去的),脚本没有机会"自报家门"。这消除了一整类故障:脚本把一条属性上报误判成方法回复,平台不会跟着一起错。

五、进出都是字符串,中间是对象

把 JS 引擎嵌进 Go 程序最容易翻车的地方是类型:Go 的结构体、map[string]interface{}、float64 与 int 之间来回倒。SagooIoT 把边界收得很窄 ------ 进出都是 JSON 字符串。

RunScriptWithData 一共四步:

复制代码
func RunScriptWithData(functionName, script, jsonData string) (string, error) {
	if functionName == "" || script == "" || jsonData == "" {
		return "", errors.New("RunScriptWithData: invalid parameters")
	}
	vm := otto.New()                       // 1. 新建 VM,注入脚本
	_, err := vm.Run(script)

	var data interface{}
	err = json.Unmarshal([]byte(jsonData), &data)  // 2. JSON -> Go 值

	value, err := vm.Call(functionName, nil, data) // 3. 调用约定函数

	result, err := value.Export()                  // 4. 返回值 -> Go 值
	jsonResult, err := json.Marshal(result)
	return string(jsonResult), nil
}

站在脚本作者的角度,看到的东西非常朴素:parse 收到一个对象,返回一个对象。不需要知道 otto 是什么,也不需要知道 Export 是什么。

复制代码
function parse(data) {
  return {
    id: data.seq ? String(data.seq) : String(Date.now()),
    version: "1.0",
    method: "thing.event.property.post",
    params: {
      temperature: data.t / 10,
      humidity: data.h / 10,
      switch: data.on === 1 ? "on" : "off"
    }
  };
}

上面这段对应清单里的第一台设备:t: 235 变成 23.5,on: 1 变成物模型里的枚举值,seq 借位成报文 id。清单里第二台把数值塞在字符串里、第三台用 [{k,v}] 数组,都只是换个写法的事:

复制代码
function parse(data) {
  var params = {};
  data.d.forEach(function (item) {
    if (item.k === "T") params.temperature = Number(item.v);
    if (item.k === "H") params.humidity = Number(item.v);
  });
  return {
    id: String(data.ts || Date.now()),
    version: "1.0",
    method: "thing.event.property.post",
    params: params
  };
}

第四步的 json.Marshal 有个附带效果:脚本返回什么类型,平台都能接住。返回对象就是标准 JSON;返回字符串,会被序列化成带转义的 JSON 字符串 ------ 平台随后会把反斜杠清理掉(下一节会讲),所以脚本里用模板拼一段设备私有报文也是可行的。

六、两级翻译:插件管字节,脚本管语义

脚本的前提是"输入已经是 JSON"。可现实里大量设备吐的是十六进制帧、定长二进制、或者带包头包尾的自定义串。这类问题脚本解决不了,也不该由脚本解决。

SagooIoT 把这条链路拆成两级。第一级是协议插件,负责把字节流变成 JSON:

复制代码
if deviceInfo.Product.MessageProtocol != consts.DefaultProtocol && deviceInfo.Product.MessageProtocol != "" {
	pluginData, err := plugins.GetProtocolPlugin().
		GetProtocolDecodeData(deviceInfo.Product.MessageProtocol, payload)
	if err != nil {
		return string(payload), errors.New(...)
	}
	pluginDataByte, _ := json.Marshal(pluginData.Data)
	jsonData = string(pluginDataByte)
}

第二级才是脚本,负责把 JSON 变成物模型 JSON。

两级分工带来一个很实际的好处:协议栈的复杂度被关在插件里,业务语义的差异被留在脚本里。Modbus 的寄存器偏移、DL/T 645 的帧格式,这些一辈子只写一次的东西放在插件;"这台设备的温度要除以 10、那台要减 40"这种一次性的、随项目变化的东西放在脚本。两边都不用为对方的变动重新发版。

网关场景更明显。tunnel-base.go 里的 router 走的是同一条顺序:

复制代码
// 通过消息协议插件解析数据
pluginData, err := plugins.GetProtocolPlugin().GetProtocolDecodeData(productDetail.MessageProtocol, data)
...
// 如果有js脚本,根据js脚本处理解析后的数据,处理后的数据数据格式为默认的消息协议格式
if productDetail.ScriptInfo != "" {
	res, runScriptErr = jsinterpreter.RunScriptWithData(jsinterpreter.FunctionNameByParse, productDetail.ScriptInfo, res)
}

注释里那句话就是这套设计的目标:处理后的数据格式为默认的消息协议格式。不管前面经过了几层,到这一步都必须收敛成平台认识的样子。

七、网关场景下,脚本还兼职做路由

子设备接网关时,一条上行消息里可能混着好几个子设备的数据,平台得知道该交给哪个物模型处理。tunnel-base.go 从脚本输出里读两个字段:

复制代码
modelFuncName, ok := dataInfo["model_func_name"].(string)
...
modelIdentifyName, ok := dataInfo["model_func_identify"].(string)
...
handleF := tunelBase.GetModelHandle(modelFuncName)
if modelFuncName == tunelBase.UpProperty {
	modelIdentifyName = "property"
}

于是网关的 parse 除了翻译字段,还顺手把路由标出来:

复制代码
function parse(data) {
  return {
    model_func_name: "up_property",
    model_func_identify: data.subDevId,
    id: String(Date.now()),
    version: "1.0",
    method: "thing.event.property.post",
    params: {
      temperature: Number(data.t) / 10
    }
  };
}

把"这是哪个子设备"的判断放进脚本,而不是做成平台侧的固定规则,是有道理的:子设备编号藏在报文的第几个字节、用的是 MAC 还是短地址、需不需要从网关缓存里反查,这些全看厂家怎么定。让脚本决定,平台只需要认识 model_func_name 这一个枚举。

八、没有脚本的产品,走的是原来的路

这一点值得单独说:整条链路上,脚本永远是一个 if。

复制代码
if deviceInfo.Product.ScriptInfo != "" {
	// ... 跑脚本
}
return strings.ReplaceAll(jsonData, "\\", ""), nil

没有脚本的产品,jsonData 就是原始 payload(或插件解码结果),后面的事情一模一样。这意味着:

  • 已经接好的标准协议产品,不会因为引入脚本机制而多一次函数调用、多一次序列化;
  • 脚本出问题时(语法错、函数不存在、JSON 不合法),processMessage 会带着原始 payload 返回错误,而不是把半成品往下游递;
  • 同一套 sagoo-core 可以同时服务"贴一段脚本就能接"和"必须走插件"的两类设备。

九、下发方向的两次善后

上行链路相对干净,下行更麻烦一点,因为报文最终要出门。down/service/service.go 在脚本返回之后做了两件事:

复制代码
requestDataStr := ""
if request.DeviceDetail.Product.ScriptInfo != "" {
	requestDataStr, runScriptErr = jsinterpreter.RunScriptWithData(
		jsinterpreter.FunctionNameBySend, request.DeviceDetail.Product.ScriptInfo, string(requestData))
}

// 对存在转义字符的进行全量替换
requestDataStr = strings.ReplaceAll(requestDataStr, "\\", "")
// 对存在变量的定义进行全量替换
requestDataStr = utils.PerformMultipleReplacements(requestDataStr, map[string]string{
	"$deviceKey":  request.DeviceDetail.Key,
	"$productKey": request.DeviceDetail.Product.Key,
})

第一次善后是反斜杠。 脚本返回的字符串会被 JSON 序列化一层," 变成 \"。设备端不认识这种转义,所以出门前统一清掉。有了这一步,脚本里就可以直接写模板:

复制代码
function send(data) {
  return JSON.stringify({
    cmd: "SET",
    dev: "$deviceKey",
    seq: data.id,
    body: data.params
  });
}

第二次善后是变量替换。 $deviceKey / $productKey 在下发前被换成真实的设备标识和产品标识。它的价值在于脚本可以被复制 :同一个产品的所有设备共用一份 script_info,脚本里不写死任何设备编号,换一台设备、换一个产品,脚本原样搬过去就能用。

十、改完就生效,还能先试跑

脚本是配置,配置就得管生效时机。UpdateScriptInfo 里除了写库,还有关键的一行:

复制代码
_, err = dao.DevProduct.Ctx(ctx).
	Data(devProduct).
	Where(dao.DevProduct.Columns().Key, in.Key).
	Update()
// 更新缓存
err = service.DevDevice().CacheDeviceDetailListByProduct(ctx, devProduct.Key)

CacheDeviceDetailListByProduct 按产品维度刷新设备详情缓存。因为脚本是挂在产品上的、运行时从设备详情的 Product.ScriptInfo 取,所以刷完缓存,这个产品下所有设备的下一帧报文就已经用上新脚本了 ------ 不用重启 sagoo-core,不用逐台设备重连。

改之前可以先试跑。产品接口上留了两个入口:

复制代码
type UpdateScriptInfoReq struct {
	g.Meta `path:"/script/update" method:"put" summary:"脚本更新" tags:"产品"`
	*model.ScriptInfoInput
}

type RunScriptReq struct {
	g.Meta       `path:"/script/run" method:"post" summary:"脚本执行" tags:"产品"`
	FunctionName string `json:"functionName"`
	ScriptInfo   string `json:"scriptInfo" v:"required#脚本信息不能为空"`
	JsonData     string `json:"jsonData"`
}

/script/run 的实现只有几行 ------ 拿函数名、脚本、一段样例 JSON,直接跑:

复制代码
func (c *cProduct) RunScript(ctx context.Context, req *product.RunScriptReq) (res *product.RunScriptRes, err error) {
	data, err := jsinterpreter.RunScriptWithData(req.FunctionName, req.ScriptInfo, req.JsonData)
	if err != nil {
		return nil, err
	}
	res = &product.RunScriptRes{Data: data}
	return
}

这就是一个"离线调试台":把现场抓到的一帧报文贴进去,选 parse,立刻看到平台最终会拿到什么。调试不需要设备在线,也不需要发布。

十一、沙箱边界:一台 VM,一次调用

jsinterpreter 用的是 github.com/robertkrimen/otto,纯 Go 实现的 ES5 解释器。这个选择决定了几件事:

维度 otto 带来的结果
依赖 纯 Go,无 CGO,交叉编译不受影响
部署 后端产物里直接带上引擎,不需要额外的运行时或 V8 动态库
隔离 每次调用 otto.New() 建独立 VM,脚本之间不共享全局变量
语法 ES5 为主,够写字段映射、数组遍历、字符串拼接

"一次调用一台 VM"在 SaaS 场景里是必要的:脚本是用户自己写的,谁也不能保证上一台设备的脚本里没有留下全局状态。每次从零开始,等于把"脚本写错影响别人"的可能性关在门外。

被放弃的是 ES6+ 的语法糖。这是个有意的取舍 ------ 脚本的职责是翻译报文,不是写业务系统。箭头函数、let、模板字符串这些省下来的字符,换来的是一个不需要 CGO、不需要 Node 运行时、任何平台都能直接跑的编译器产物。

十二、什么写进脚本,什么写进插件

最后给一个判断口径。两者都能"处理数据",但边界其实很清楚:

特征 写进产品脚本 写进协议插件
输入形态 已经是 JSON 二进制帧 / 十六进制 / 自定义串
变化频率 随项目、随设备型号变 随协议规范变,极少改
谁来写 实施、现场工程师 研发
生效方式 /script/update + 刷缓存,立即生效 安装插件、重启进程
典型工作 字段改名、量纲换算、枚举映射、子设备路由、自定义回执 寄存器读取、帧头校验、CRC、编解码

一句话概括:跟字节打交道的归插件,跟字段打交道的归脚本。

结语

设备接入这件事,难的部分从来不是"支持多少种协议",而是"这一台和上一台差在哪"。差在字段名,就不该动代码;差在字节序,才该动插件。

SagooIoT 用一个 script_info 字段和三个函数名(parse、send、reply),把前者从编译期挪到了运行期。它不是什么新概念,但确实把现场那句"这个要排期"变成了"这个我自己改一下"。

项目地址

主仓库:https://github.com/sagoo-cloud

文档站:https://iotdoc.sagoo.cn

本文代码基于 SagooIoT 专业版本地发行版,涉及 pkg/jsinterpreter、network/core/router.go、network/core/tunnel/tunnel-base.go、network/core/logic/model/up/event/event.go、network/core/logic/model/down/service/service.go、internal/controller/product/product.go、internal/logic/product/dev_product.go。

相关推荐
Bug收容所1 小时前
Vue+JS Day3
前端·javascript·vue.js·前端框架·1024程序员节
紧跟先前的步伐1 小时前
Go AI Agent 实战 15|context.Context、超时与取消机制
人工智能·安全·ai·golang·1024程序员节
编程的一拳超人1 小时前
【元宝搜索开发者征文大赛】四语实战:Java/Python/Go/Node.js 构建 AI 搜索应用全攻略
java·python·golang·node.js·agent
小羊没烦恼!2 小时前
.NET无侵入自动化探针原理和主流实现
运维·开发语言·javascript·python·自动化·.net·1024程序员节
IT规划师2 小时前
MQTT实战 - 遗嘱消息+心跳机制,让你的设备永远不会“假在线”(七)
物联网·.net
Linux_cy11 小时前
服务器管理工具哪个好用?我用了七八个
运维·服务器·javascript·python·ssh·面板·gmssh
变与不变80611 小时前
jQuery细节以及使用应用场景
前端·javascript·jquery
fly-iot14 小时前
【下一个 AI 方向】:中小企业的数字化建设,物联网解决方案思考,使用开源项目快速搭建降低成本。
人工智能·物联网