先应答,再入库:SagooIoT 接入 GB/T 32960 的四层宿主改造

GB/T 32960 里有一条容易被忽略的时序约定:终端每发出一帧,都在等平台回一个通用应答;等不到就判定链路不可用,断开、重连、重新登入。真车上的终端严格照做,于是"平台能不能立刻把回应吐回去"这件事,直接决定这条链路是稳稳挂着,还是每隔几分钟抖一次。

SagooIoT 原来的 TCP 入站路径没有"回应"这个动作。它的流程是:读 chunk → 注册包取 deviceKey → Protocol 插件 Decode → 组物模型信封 → 入库。从头到尾不回写一个字节------因为此前接入的协议都不需要。海湾 GST-TCP 是单向 JSON 上报,设备发完就完事;Modbus DTU 走的是"平台问、设备答"的半双工隧道,也不靠协议帧里的应答字段。

GB/T 32960 不一样。它一次把宿主侧的四个缺口全撞了出来。

一、一个只会"收"的入站路径

把缺口按"接哪个协议才会痛"排一下,是这样一张表:

缺口 具体表现 谁先撞上
粘拆包配置没生效 network_server.stick 在库里、在 API 里、在模型里都齐了,但读循环按 4KB Read 之后直接投递,没人消费它 任何变长二进制协议
没有同连接应答 TunnelBase.ReadData 在 Decode 成功后只做 json.Marshal,从不 Link.Write 会话型协议
注册包偏文本 RegisterPacket.Check 先跑 normalizeRegisterPayload(去换行、去首尾空白、剥掉末尾 )))),再走正则或定长 二进制首包
并发读无帧缓冲 dispatchRead 对每帧 safe.Go 并发,上限 maxInflightReads = 64 会连发多帧的协议

顺带还有一个认知层面的缺口:服务级心跳配置,对入站 TCP 来说是死配置。这个后面单列一节。

四个缺口里,前三个是"能力不存在",第四个是"能力存在但不该用在这里"。它们共同说明一件事:协议插件不是万能的。插件跑在独立进程里、通过 RPC 收字节流,它能看到的东西只有"宿主喂给它的那段数据"。帧边界、注册身份、写回时机,全在插件够不着的地方。

二、第一处:Stick 配了很久,从来没人读过它

network_server 表上有个 stick 列,管理端的网络服务表单里能填,手册里也写了四种粘拆包方式(分隔符 / 固定长度 / 长度字段 / JS 自定义)。但在这轮改造之前,它只是一段存在数据库里的 JSON 字符串。

原因在 mapper:Register、Heartbeat、Protocol 三个字段都被 StrToPointInterfaceWithoutError 转成了运行时结构,唯独 Stick 只是把字符串赋进模型,没有解析。于是拆帧器根本没有东西可跑。

修法是把 Stick 也做成可解析结构,并在读循环里真正调用它:

go 复制代码
// network/connection/stick/stick.go
//
//	totalFrameLen = Offset + Len + lengthValue + Adjustment
//
// Example GB/T 32960: Offset=22, Len=2, big-endian data-unit length, Adjustment=1
// → total = 24 + dataLen + 1.
type LenField struct {
	Len        int    `json:"len"`
	Offset     int    `json:"offset"`
	Endian     string `json:"endian"` // big | little (also 大端/小端)
	Adjustment int    `json:"adjustment,omitempty"`
}

GB/T 32960 的帧长是 固定头 24 + 数据单元长度 + BCC 1。而长度字段本身在 offset 22、占 2 字节,所以:

复制代码
adjustment = 24 - 22 - 2 + 1 = 1

也就是说,adjustment 这个字段的存在意义,仅仅是让运维不用手算"头尾那 25 个字节"。实现在 nextLength() 里只有一行:

go 复制代码
total := lf.Offset + lf.Len + int(lengthValue) + lf.Adjustment

拆帧点定在"读循环拿到 chunk 之后、投递 DeliverInbound 之前",每连接一个 frameBuf 串行处理。定在这个位置的原因很直接:插件是通过 RPC 被调用的,可能在多个 goroutine 里并发进来,让插件自己拼包必然竞态。

拆不出来的时候,怎么恢复

半包和粘包是正常流量,不算错误;真正需要写死的是解出了非法长度怎么办:

情况 行为
缓冲不足一整帧 等待更多数据,不推进
长度字段算出的 totalFrameLen ≤ 已读头长,或 > maxFrame 丢 1 字节滑窗,继续找下一个候选
再读一次长度字段仍非法 同样丢 1 字节,直到缓冲耗尽
单帧超过 maxFrame(默认 64 KiB) 按非法长度处理,不断连
连接关闭 丢弃残余缓冲

"丢 1 字节"而不是"清空缓冲"是个刻意的选择:清空会把紧跟在坏数据后面的合法帧一起误杀,滑窗最多多扫几个字节。同理,长度字段非法或超上限都不断连------断连的代价是让设备重登一次,而重登会引发一整轮会话重建,比多扫几十字节贵得多。

另外两处细节:

  • readLength 只认 1 / 2 / 4 字节的长度字段。出现别的宽度时返回 ok=false,走滑窗分支,而不是按 0 处理。按 0 会解出"长度为 0 的帧"这种幻觉。
  • 字节序判定是白名单:little / 小端 / le / LE 算小端,其余一切(包括写错的 Big、big-endian、留空)都按大端。这里没有报错,只有默认值------写错了不会有人告诉你。

分隔符模式还有一条不同的兜底:找不到分隔符且缓冲已超过 maxFrame 时,保留尾部 maxFrame/2 字节(至少保留一个分隔符长度),其余丢掉。留一半是因为分隔符可能正好骑在被截断的位置上。

Enabled() 的判定是"四个字段任一非空",所以空配置 = 关闭拆帧 = 老路径原样。这是这次改造能安全上线的前提:所有历史 TCP 服务(gst-tcp、protocol-binary、Modbus DTU)都保持原行为,只有显式配了 Stick 的服务才走新路径。

三、第二处:Decode 多了一个 reply 字段

协议插件的 Decode 返回值一直是 model.JsonRes{Code, Message, Data}。为了让插件能把应答帧交回来,Data 这个 map 里多了一个约定字段:

json 复制代码
{
  "model_func_name": "nop",
  "reply": "<base64 of response frame>"
}

宿主侧的处理在 TunnelBase.ReadData 里:

go 复制代码
// reply:base64 字符串;先写回再入库(会话型协议降低车端超时)
applyProtocolReply(ctx, deviceKey, dataInfo, l.Write)

modelFuncName, ok := dataInfo["model_func_name"].(string)
if !ok || modelFuncName == "" || modelFuncName == "nop" {
	// 仅应答(如心跳 ACK):跳过物模型管线
	return
}

三条语义写死在这里:

  1. 先写回,再入库。车端在等回应,把应答放到入库之后,等于给数据库的写入延迟加到了车端的等待时间里。
  2. nop 不入库。心跳帧只应答、不产生任何物模型数据。如果没有这个约定,每一帧心跳都会被当"空数据上报"塞进管线。
  3. reply 解码失败不阻断入库 。applyProtocolReply 里非法 base64 只打一条 Debug 日志,然后继续往下走。

为什么不直接传 []byte

这是设计阶段推翻过一次的地方。最初的方案是让 reply 为 []byte,理由是"走 gob 序列化能保住原始字节"。但这条路走不通,因为数据要过三次序列化:

  1. RPC 层对 Data 做 json.Marshal / Unmarshal------[]byte 到这里就变成 base64 字符串了。
  2. 宿主 ReadData 拿到 pluginData.Data 后又 json.Marshal 一次,再 Unmarshal 回 map[string]any。
  3. 进 map 之后,任何字节切片都只会以字符串形式存在。

与其让"gob 曾经保住过、json 之后又丢了"这种语义分裂存在,不如直接约定 reply 就是标准 base64 字符串,只做一次编解码。契约简单,比"看起来更类型安全"重要。

开着 Stick 的连接,不再并发 Decode

把 reply 加进来之后,一个新问题浮出来:dispatchRead 原本对每帧 safe.Go 并发执行,最多 64 个在飞。多帧连发时,写回的顺序没有保证------车端可能先收到心跳应答,后收到登入应答。

处理办法是加一个开关:

go 复制代码
// serialInbound:Stick 开启时串行 Decode+reply,避免应答乱序
serialInbound bool

newServerTcpTunnel 把 useStick 直接透传进来,所以:

  • Stick 开启的连接:完整帧在该连接上串行执行"Decode → 写 reply → 投递入库",不再并发。会话型协议通常低频(GB/T 32960 的实时上报周期在秒级到十秒级),串行的吞吐损失可以接受。
  • Stick 关闭的连接:保持原来的并发行为,gst-tcp 这类高吞吐单向协议不受影响。

同时给 Write 加了互斥锁,因为写回和平台下行 Encode 共用同一条 TCP 连接,两个 goroutine 各写一半会直接把帧切碎:

go 复制代码
// writeMu:reply 与下行 Encode 共用,避免半包交织
writeMu sync.Mutex

顺序上,reply 和下行命令仍可能交错(协议本身允许),但每一次 Write 是原子的。

还有一个安静的坑值得记一笔:Write 在透传模式下会直接返回成功:

go 复制代码
if l.pipe != nil {
	return nil //透传模式下,直接抛弃
}

也就是说,如果一个隧道被接上了透传管道,插件的 reply 会被无声吞掉,车端等到超时。透传是独占语义,和"协议插件要应答"是互斥的两种用法,配的时候得知道自己选的是哪个。

四、第三处:VIN 得从二进制里抠出来

设备身份从哪来?RegisterPacket.Check 原本只有两条路:正则匹配,或固定长度。两条路都要先过 normalizeRegisterPayload:

go 复制代码
func normalizeRegisterPayload(buf []byte) string {
	s := string(buf)
	s = strings.ReplaceAll(s, "\n", "")
	s = strings.ReplaceAll(s, "\r", "")
	s = strings.TrimSpace(s)
	for strings.HasSuffix(s, ")))") {
		s = strings.TrimSuffix(s, ")))")
		s = strings.TrimSpace(s)
	}
	return s
}

这套规整是为海湾 GST-TCP 那类"JSON 尾巴带 ))) 分隔符"的文本协议准备的。但对二进制帧来说,它是破坏性的 :GB/T 32960 的 VIN 是 17 个定长字节,帧头是 0x23 0x23,数据单元里可能出现任意字节。先去空白再匹配,等于拿着错误的字节在找身份。

所以新增了一条完全独立的路径,用一个判据分流:

go 复制代码
func (p *RegisterPacket) Check(buf []byte) (deviceKey string, checkOk bool) {
	// binaryOffset:必须用原始字节,禁止 normalize(会破坏二进制帧)
	if p.Size > 0 {
		if p.HexPrefix != "" {
			want, err := hex.DecodeString(p.HexPrefix)
			if err != nil || len(buf) < len(want) || !bytes.Equal(buf[:len(want)], want) {
				return "", false
			}
		}
		if p.Offset < 0 || p.Offset+p.Size > len(buf) {
			return "", false
		}
		keyBytes := buf[p.Offset : p.Offset+p.Size]
		// ...
	}
	data := normalizeRegisterPayload(buf) // 老路径
	// ...
}

Size > 0 就是分流开关:填了就从原始字节切片,一个字都不改;没填就走老的文本路径。GB/T 32960 的配置因此长这样:

字段 值 说明
offset 4 跳过 ##(2) + 命令(1) + 应答(1)
size 17 VIN 定长 17 字节
trimNull true 去掉尾部 0x00 与空格
hexPrefix 2323 魔数校验,不匹配直接拒绝

trimNull 只对 raw 编码生效 ------这是刻意的。hex 和 uint 都按定长字节解释,尾部补零是数值的一部分,裁掉会改变含义。切片怎么变成 deviceKey 由 keyEncoding 决定:raw 直接 string(b),hex 转小写十六进制,uint 按 1~8 字节无符号整数、字节序可选 big/little(默认 big)。

配置错误在服务启动时就被拦下,而不是表现为"所有设备注册失败且日志没线索":

go 复制代码
switch registerKeyEncoding(p.KeyEncoding) {
case "raw", "hex":
	return nil
case "uint":
	if p.Size > 8 {
		return fmt.Errorf("keyEncoding=uint 时 size 须为 1~8,当前 %d", p.Size)
	}
	// ...
default:
	return fmt.Errorf("keyEncoding 无效: %q(可选 raw/hex/uint)", p.KeyEncoding)
}

最后一处配套改动:Stick 开启时,注册用的是第一个完整帧。因为二进制帧可能被 TCP 切成两段到达,拿半包去取 offset 4 开始的 17 个字节,取到的是残缺的 VIN。所以读循环变成"先拆帧、再注册、然后把拆出来的第一批帧一起投递":

go 复制代码
if useStick {
	frames := splitter.Feed(chunk)
	if len(frames) == 0 {
		continue // 半包,继续读
	}
	deviceKey, checkIsOk = server.server.Register.Check(frames[0])
	// ...
	initialFrames = frames
	registered = true
}

顺带说一句"同 VIN 踢旧":新连接建立时,宿主会关闭同 deviceKey 的旧入站隧道------但只在 Stick 开启时做。空 Stick 的服务不踢,避免改变 gst-tcp 与 Modbus DTU 的既有行为。这条限制配的是下面第八节那个取舍。

五、第四处:服务级心跳其实是个死配置

网络服务配置里有一个"心跳"选项,看着像是用来判活的。但对入站 TCP 来说,HeartBeatPacket.Check 只在客户端出站隧道(tunnel-client)里被调用,ServerTcpTunnel 的投递路径从来不碰它。

所以过去的文档里那句"会话型协议请关闭服务级心跳过滤",前提根本不成立------没有一处代码在读它 。GB/T 32960 的 0x07 心跳只能走插件应答这条路:插件把它解成一帧应答,model_func_name 给 nop,宿主写回连接、跳过入库。

这轮改造没有"顺便把入站心跳也实现一下"。原因是判活语义要看协议:GB/T 32960 有自己的 0x07 周期心跳,设备端清楚自己在不在线;而通用层如果按"最后收帧时间"判活,会在设备批量补发历史数据时误判。要做就单独立项,别拿一个从来没生效过的配置项冒充"已经支持"。

六、帧结构:24 个字节的头,和 1 个字节的尾

宿主侧说完了,剩下是协议插件自身。GB/T 32960 一帧的结构是定长的头 + 变长的数据单元 + 校验尾:

偏移 长度 字段 说明
0 2 起始符 ##(0x2323) = 2016;$$(0x2424) = 2025
2 1 命令标识 见下一节的命令字表
3 1 应答标志 0xFE = 车辆主动发送;0x01/0x02/0x03 = 平台应答
4 17 VIN 车辆识别代号,定长,尾部补 0x00
21 1 加密方式 0x01 明文 / 0x02 RSA / 0x03 AES128
22 2 数据单元长度 大端 uint16,只算数据单元,不含头尾
24 N 数据单元 加密时这里是密文
24+N 1 BCC 从命令字开始到数据单元结束逐字节 XOR

解析时先卡最小长度,再按起始符判版本:

go 复制代码
if len(data) < MinPacketLen {          // MinPacketLen = 25
	return nil, errors.New("frame too short")
}
switch {
case data[0] == 0x23 && data[1] == 0x23:
	ver = Version2016
case data[0] == 0x24 && data[1] == 0x24:
	ver = Version2025
default:
	return nil, fmt.Errorf("bad start marker %02X%02X", data[0], data[1])
}
dataLen := binary.BigEndian.Uint16(data[22:24])
total := HeaderLen + int(dataLen) + 1

BCC 的覆盖范围值得单独看一眼:

go 复制代码
func verifyBCC(frame []byte) bool {
	if len(frame) < MinPacketLen {
		return false
	}
	return frame[len(frame)-1] == xorBCC(frame[2:len(frame)-1])
}

frame[2:len-1]------不含起始符,也不含 BCC 自己。起始符是帧同步用的,不参与校验;BCC 自己更不可能参与。RSA 场景下密文长度会变,但校验范围的定义不受影响。

另外两处边界:EncodePacket 里 VIN 被 copy 进一个固定 17 字节的切片,超长静默截断 ;数据单元超过 0xffff 也被截到 0xffff。解析侧则只从右侧裁掉 0x00 和空格:

go 复制代码
vin := strings.TrimRight(string(data[4:21]), "\x00 ")

七、0xFE:平台只处理"车辆主动发"的那一半

帧头里第 3 个字节是应答标志。GB/T 32960 用它区分"命令"和"应答",而插件只认其中一半:

go 复制代码
if pkt.Response != RespCommand {   // RespCommand = 0xFE
	return model.JsonRes{Code: 0, Data: map[string]any{"model_func_name": "nop"}}
}

0xFE 表示车辆主动发送;0x01/0x02/0x03 是平台应答。发往平台的应答帧一律按 nop 处理------不报错、不入库、也不回写应答。这是对的:对一条应答再发一条应答,会变成无休止的乒乓。

真正处理的命令只有六个:

命令 名称 插件行为
0x01 车辆登入 记录 VIN 会话,回事件 login + 成功应答
0x02 实时信息 解析信息体 → 属性上报 + 应答
0x03 补发信息 与 0x02 同路处理(补发的是历史帧,字段结构一致)
0x04 车辆登出 清会话 + 回事件 logout + 应答
0x07 心跳 只有应答,nop
0x08 终端校时 应答里带上平台当前时间

其余命令(含 0x05 平台登入)走 default 分支:回一个通用应答,不入库。应答帧的数据单元是回显请求的前 6 个字节:

go 复制代码
func EchoTime6(dataUnit []byte) []byte {
	out := make([]byte, 6)
	if len(dataUnit) >= 6 {
		copy(out, dataUnit[:6])
	}
	return out
}

这是国标里通用应答的写法:把请求里的时间原样带回去。唯一的例外是 0x08 校时------那个要用平台自己的当前时间,不能回显。

还有一处细节:应答帧的加密方式被固定写成 EncNone。即使请求是 AES 加密的帧,应答也走明文。这是因为通用应答的数据单元只有 6 个时间字节,不含任何敏感信息,而让应答也加密会给车端增加一次解密负担,还会引入"key 不对导致应答收不到"的新故障模式。

八、没有登入态,实时帧直接拒

ProtocolGbt32960 里挂了一个会话表,进程内、按 VIN 索引、sync.Mutex 保护:

go 复制代码
type sessionState struct {
	LoginUnix int64
	Version   ProtocolVersion
}

type sessionStore struct {
	mu sync.Mutex
	m  map[string]*sessionState
}

三个方法:Get / Login / Logout。规则写得很硬:

  • 收到 0x07 心跳或 0x02/0x03 数据帧时,如果 VIN 不在表里,回失败应答 (应答标志 0x02),数据不入库。
  • 收到 0x01 登入时,覆盖已有会话,回成功应答。

第二条是这轮改造的取舍结果,值得展开一下。国标里有一个 0x03 应答码,含义是"VIN 重复登入"。字面看,同一台车第二次登入应该回 0x03 拒绝。但真实现场是这样的:4G 信号断一下,车端重连,TCP 是新连接、VIN 是老的,插件如果回 0x03,车端会认为登入失败,继续重试------重连反而被判成非法。

所以定下来的组合是:

层 行为
宿主 Stick 开启时,新连接建立前关闭同 deviceKey 的旧隧道
插件 登入覆盖会话,一律回成功(0x01)

RespVINDup = 0x03 这个常量在代码里保留着,但没有任何一处调用它。旧连接已经被宿主踢掉了,插件看到的"重复登入"其实是重连,回成功比回拒绝更接近真实语义。

会话表是进程内的,这是明确的边界而不是疏漏:

  • 插件进程重启,会话全丢,车端必须重新登入一次。对车联网场景可以接受------车端本来就有重连逻辑。
  • 如果插件被部署成多实例,每个实例各存一份会话,同一台车的不同帧可能落到不同实例,会话态不共享。要做跨连接、跨实例的会话中心,得引入外部存储,这属于另一个量级的改造。当前的做法是把会话看作"连接级状态"而不是"全局状态"。

九、0x07 和 0x08:同一个编号,两套含义

GB/T 32960 的信息体是"编号 + 长度"的自描述结构:一个字节的编号,后面跟着该编号对应的固定或变长数据。这是它设计得好的地方------可以只解自己关心的那几类,其他直接跳过。

但 2016 版和 2025 版共用了一套编号空间,却给 0x07 和 0x08 分配了完全不同的含义:

信息体编号 2016 版含义 2025 版含义
0x01 整车数据(20 字节定长) 整车数据(同结构)
0x02 驱动电机数据(1 + N×12) 驱动电机数据
0x03 燃料电池数据 燃料电池数据
0x04 发动机数据(5 字节) 发动机数据
0x05 车辆位置(9 字节) 车辆位置
0x06 极值数据(14 字节) 极值数据
0x07 报警数据 动力蓄电池电压
0x08 可充电储能装置电压 动力蓄电池温度
0x09 可充电储能装置温度 ---
0x30 --- 燃料电池电堆(摘要)

这意味着同一个 0x07,按 2016 解出来是"最高报警等级 + 通用报警标志 + 故障码列表",按 2025 解出来是"电池包数量 + 单体数量 + 单体电压列表"。用错版本不会报错,只会静默产出垃圾数据。

所以 parseRealtime 的签名里带着版本,而两个分支的判定条件必须同时写上版本号:

go 复制代码
case ver == Version2025 && infoType == 0x07:
	n = parseBatteryVoltage2025(rest, props)
case ver == Version2025 && infoType == 0x08:
	n = parseBatteryTemp2025(rest, props)
case ver == Version2016 && infoType == 0x07:
	n = parseAlarm2016(rest, props)
case ver == Version2016 && infoType == 0x08:
	n = parseStorageVoltage2016(rest, props)

0x02(驱动电机)和 0x03(燃料电池)这两个分支没有版本判断 ,因为两个版本的字段布局按国标是一致的。而 0x30 那一支也没有版本判断------注释指向 2025 的燃料电池电堆摘要,但 2016 的报文如果带上这个编号,同样会按 2025 语义解析。2016 版没有定义 0x30,所以这属于"宁可多解一次,也不多写一个判断"的取舍。

版本从哪来?就来自第一个字节:## 是 2016,$$ 是 2025。帧头自带版本标识,不需要额外配置。

十、一个函数吃掉四种变长列表

信息体里最麻烦的不是定长结构,是"带数量的列表"。GB/T 32960 里有四种:

结构 头长度 数量的位置 数量宽度 每项长度
2016 储能装置电压 10 字节 第 9 字节 uint8 2 字节
2016 储能装置温度 3 字节 第 1 字节 uint16 1 字节
2025 电池电压 7 字节 第 5 字节 uint16 2 字节
2025 电池温度 3 字节 第 1 字节 uint16 1 字节

四种结构看着不一样,抽出来其实是同一个模式:"数量的位置"和"数量宽度"和"每项长度"三个参数的不同组合。于是一个函数就够:

go 复制代码
// countListLen walks a count-prefixed list: data[0] is the item count, each item
// is head bytes plus a variable part sized by a nested count at nestedAt within
// the head (nestedWidth 1 = uint8, 2 = uint16 BE) times scale.
func countListLen(data []byte, head, nestedAt, nestedWidth, scale int) int {
	if len(data) < 1 {
		return 0
	}
	count := int(data[0])
	off := 1
	for range count {
		if len(data) < off+head {
			return 0
		}
		var n int
		if nestedWidth == 1 {
			n = int(data[off+nestedAt])
		} else {
			n = int(binary.BigEndian.Uint16(data[off+nestedAt : off+nestedAt+2]))
		}
		off += head + n*scale
	}
	return off
}

四个调用点各自只差那一行:

go 复制代码
func storageVoltageLen2016(data []byte) int { return countListLen(data, 10, 9, 1, 2) }
func storageTempLen2016(data []byte) int    { return countListLen(data, 3, 1, 2, 1) }
func batteryVoltageLen2025(data []byte) int { return countListLen(data, 7, 5, 2, 2) }
func batteryTempLen2025(data []byte) int    { return countListLen(data, 3, 1, 2, 1) }

注意这里的返回值是**"这一项占多少字节",不是"解出了什么数据"**。解析函数拿到长度后,只解第一项,其余原样跳过:

go 复制代码
if len(rest) >= 8 {
	props["battery_pack_count"] = rest[0]
	props["pack1_voltage"] = float64(binary.BigEndian.Uint16(rest[2:4])) * 0.1
	props["pack1_current"] = float64(binary.BigEndian.Uint16(rest[4:6]))*0.1 - 3000
	props["pack1_cell_count"] = binary.BigEndian.Uint16(rest[6:8])
}

一个电池包最多几百个单体,一台车最多几个包,全量展开会是几百个属性。展平第一包的关键量、其余只用来推进游标------这样既拿到了最有诊断价值的量(包电压、包电流、单体数),又不会把物模型撑成一个几百字段的宽表。

代价也很明确:物模型里 pack1_voltage 这个名字只代表"第一个包"。多包车辆的第二个包去哪了,要等到有人真的需要时再谈。

storageVoltageLen2016 里还有个容易看漏的细节:电压取 rest[2:4] 而不是 rest[1:3]。因为头两个字节是"子系统数 + 子系统号",电压从第 3 个字节才开始。这类偏移错一位,读出来的就是完全错误的数值,而且不会报错。

十一、量纲与偏置:读对了字节,还得换算对

定长结构解出来的是原始整数,国标给了每一条的换算规则。整车数据那 20 个字节里挤了 13 个量:

属性 原始 换算
speed uint16 BE × 0.1,km/h
mileage uint32 BE × 0.1,km
total_voltage uint16 BE × 0.1,V
total_current uint16 BE × 0.1 − 1000,A
soc uint8 直读,%
insulation_resistance uint16 BE 直读,kΩ
accel_pedal / brake_pedal uint8 直读,%
vehicle_status / charge_status / run_mode / dc_dc_status / gear uint8 直读,枚举

最需要留神的是三处偏置:

go 复制代码
props["speed"] = float64(binary.BigEndian.Uint16(b[3:5])) * 0.1
props["total_current"] = float64(binary.BigEndian.Uint16(b[11:13]))*0.1 - 1000

total_current 的 −1000 是为了让"充电/放电"两个方向都能落在同一个无符号字段里(0.1A 精度下 ±1000A 的范围)。同类偏置还有:电机转速 −20000 r/min(motor1_speed)、温度 −40 ℃(max_temp / min_temp / pack1_temp0)、2025 的电池包电流 −3000。

而且同一个物理量在不同信息体里的偏置量不一样 :2016 的 storage1_current 是 −1000,2025 的 pack1_current 是 −3000。这不是笔误------两版的数据单元字段宽度和量程都变了。

坐标的换算容易被忽略:

go 复制代码
lon := int32(binary.BigEndian.Uint32(b[1:5]))
lat := int32(binary.BigEndian.Uint32(b[5:9]))
props["longitude"] = float64(lon) * 1e-6
props["latitude"] = float64(lat) * 1e-6

先转 int32 再乘 1e-6,因为经度有正负(西经为负),而原始字段是无符号的。直接 float64(uint32(...)) * 1e-6 在西经位置会得到一个 4000 多度的经度。

极值数据里的电压精度比整车数据高一个量级:整车电压是 ×0.1,极值电压是 ×0.001。同一个"电压"在同一个协议里有两种精度,混用会让单体电压看起来只有实际值的三分之一。

还有一个类型不一致的地方值得一提:vehicle_status 之类直接赋的是 uint8,而 speed 赋的是 float64,motor1_speed 赋的是 int。这些值最后都要经过物模型做类型校验,物模型里把属性定义成 double,而插件塞进去的是 uint8,就会在入库时被拒。写物模型的时候得照着插件的实际类型来,不能照着自己想象中的类型来。

十二、时间、加密,和几处静默

时间是"本地墙钟"

GB/T 32960 的时间字段是 6 个字节:年(减 2000)、月、日、时、分、秒。没有时区信息。插件里的处理是直白的:

go 复制代码
// timeDateUnix builds unix seconds from calendar fields in local timezone.
func timeDateUnix(y, mo, d, h, mi, s int) int64 {
	return time.Date(y, time.Month(mo), d, h, mi, s, 0, time.Local).Unix()
}

time.Local 意味着结果取决于插件进程所在机器的时区 。在国内部署、时区为 UTC+8 的机器上是对的;容器里如果 TZ 没设、跑在 UTC 上,每条数据的时间戳都会偏 8 小时。这不是 bug,是协议本身没带时区导致的天然歧义------但它是部署时必须确认的一件事。

登入时间的处理藏了一个细节:handleLogin 优先用报文里的时间 ,解析失败才退回 time.Now():

go 复制代码
loginUnix := time.Now().Unix()
if u, ok := ParseTime6(pkt.DataUnit); ok {
	loginUnix = u
}

ParseTime6 里有基本的合法性检查(月份 1~12、日期 1~31),越界就返回 ok=false。所以一台时钟没校准的车不会把"1970"这个时间戳写进会话表。

加密:AES-128-ECB,没有 IV

加密方式字段有三个取值,插件实际支持两个:

go 复制代码
switch encrypt {
case EncNone, 0:
	return dataUnit, nil
case EncRSA:
	return nil, errors.New("RSA encryption not supported; use plaintext or AES128")
case EncAES128:
	key := aesKeyFromEnv()
	if key == nil {
		return nil, errors.New("AES128 frame but GBT32960_AES_KEY unset")
	}
	return aesECBDecrypt(key, dataUnit)
default:
	return nil, errors.New("unknown encryption mode")
}

EncAES128 是 AES-128-ECB + PKCS7 填充 。ECB 模式在现代密码学里是被诟病的(相同明文块产生相同密文块,泄露结构信息),但国标就是这么定的,插件不能自己改成 CBC------改了就跟车端对不上。

EncRSA 明确返回错误而不是"尽力而为"。一个不支持的加密方式如果静默降级成明文解析,会产出一堆看起来像数据的垃圾;明确报错至少能让运维看到真正的原因。

密钥从环境变量 GBT32960_AES_KEY 读,两种写法都认:32 个十六进制字符,或直接 16 字节原文。没配密钥时收到加密帧会被拒绝 ,但拒绝方式很讲究------nopReply(..., RespFail) 加一个 encrypt_error 字段:

go 复制代码
du, err := maybeDecryptDataUnit(pkt.Encrypt, pkt.DataUnit)
if err != nil {
	out := nopReply(pkt, pkt.Command, RespFail)
	out.Data.(map[string]any)["encrypt_error"] = err.Error()
	return out
}

车端会收到一个失败的应答(而不是石沉大海),运维可以从应答里读到原因。这一帧不入库。

三处静默

写协议解析最怕的不是解析失败,是解析成功但结果不对。这份实现里有三处属于这个类别:

第一,未知信息体编号直接截断后续。 parseRealtime 的 default 分支是这样写的:

go 复制代码
default:
	return tsUnix, props, ""

也就是说,只要碰到一个不认识的编号,这一帧后面所有信息体全部丢弃,而且不报错、不记日志。一帧 0x02 里通常有 3~6 个信息体,如果车端发了一个 2025 才定义、而平台按 2016 在解的编号,后面的位置、极值、电池数据会一起消失。物模型里表现为"这几条数据时有时无"。

第二,解析函数的错误信息被丢掉了。 handleRealtime 拿返回值时用了下划线:

go 复制代码
ts, props, _ := parseRealtime(pkt.Version, pkt.DataUnit)

parseRealtime 的第三个返回值是 errMsg,里面写好了 "vehicle data short"、"location data short" 这类具体原因。但在调用点直接被丢弃------既不进日志,也不进应答。排障时只能看到"属性少了几个",看不到"为什么少"。

第三,parseFuelCellSimple 的失败也是静默的。 别的分支长度不足会返回一句具体的错误消息,这一支返回的是空串:

go 复制代码
case infoType == 0x03: // 燃料电池:完整长度 = 7 + N探针 + 8
	n = parseFuelCellSimple(rest, props)
	if n == 0 {
		return tsUnix, props, ""
	}

这三处都不是崩溃,都是在"少一点数据"的边缘上悄悄退化。协议解析代码里,静默退化比崩溃更难查------崩溃至少有个栈。

十三、不做的,和没做的

项 状态 原因
平台登入 0x05 不做 那是平台与平台之间互联用的,车载终端不用这条
RSA 加密(0x02) 不做 只支持明文与 AES128,遇到 RSA 帧明确报错
2025 版全量信息体 只做主路径子集 整车、电机、位置、极值、电池电压/温度已覆盖;其余待有真实数据源再补
跨进程会话中心 不做 会话是连接级状态;进程重启后车端重新登入,可接受
真车端到端联调 未做 用自带模拟器顶上
入站心跳判活 不做 见第五节,通用层判活会在批量补发时误判

"用模拟器顶上"不是一句敷衍。tools/simulator/gbt32960 是一个自带的最小车端,登入 → 周期实时 → 心跳,二十来行的实时数据单元构造:

go 复制代码
du = append(du, time6(t)...)
du = append(du, 0x01) // vehicle
body := make([]byte, 20)
body[0] = 1                                  // 启动
body[2] = 1                                  // 纯电
binary.BigEndian.PutUint16(body[3:5], 120)   // 12.0 km/h
binary.BigEndian.PutUint32(body[5:9], 10000) // 1000.0 km
binary.BigEndian.PutUint16(body[9:11], 3600) // 360.0 V
body[13] = 75                                // SOC

跑起来只差两条命令:

bash 复制代码
cd tools/simulator
GOWORK=off go run ./gbt32960 \
  -platform 127.0.0.1:32960 \
  -vin LSVA12E40N2186903 \
  -period 5s \
  -heartbeat 30s

-v2025 一个开关就能切到 $$ 起始符,用来验双版本分支。配合平台侧"填入推荐配置"的网络服务(注册 offset=4 size=17 trimNull + Stick offset=22 len=2 big adjustment=1),整条链路的验证不需要一辆车。

最后

这次改造真正有意思的地方,不是协议本身------GB/T 32960 的编解码在国标文档里写得足够清楚,一个熟练的 Go 工程师照着抄一遍大概两三天。有意思的是平台为了承接它必须改什么。

"只写一个协议插件就够了"这个假设,在遇到第一个会话型二进制协议时就破了。插件跑在独立进程里,隔着 RPC 看字节流,它天然看不到三件事:帧从哪切、连接属于谁、回应写到哪。这三件事必须由宿主提供,插件才有条件把自己的协议逻辑写好。

反过来说,宿主提供的这三样能力必须是可选的、默认关闭的。这个方案能安全上线,靠的就是"空 Stick = 老路径"这一条------新增的能力全部挂在显式配置后面,没有一处改动会顺手改变既有协议的行为。加能力容易,加能力而不同时惊动存量难。

项目地址:

相关推荐
不悔哥1 小时前
小智AI聊天机器人:ESP32上的开源语音助手拆解
人工智能·机器人·开源
菩提小狗2 小时前
每日极客日报 · 2026年10月06日
ai·开源·极客日报·it热点·技术资讯
独孤九剑打醒他3 小时前
【原创开源】【概念设计】源 - 栅 - 漏 - 栅 - 源 横向双栅 MOS,低压交流多值逻辑芯片探索
前端·其他·架构·开源·硬件工程
miofly3 小时前
ChatGPT 账号用户可免费使用 Auto-review 双智能体审查功能
开源·github
mantou1323 小时前
我做了个 App:在手机上指挥电脑里的 Claude Code / Codex,还能直接看它产出的图表和模拟器画面
开源·ai编程·claude
文慧的科技江湖4 小时前
2026年10月6日充电桩行业早报:79个服务区“特别忙”,峰值不是建出来的 | 慧知开源充电桩平台
开源·充电桩
Maynor9965 小时前
Claude Opus 5.5 最强可视化案例合集:114 精选 + 1000+ 完整清单(含提示词)
人工智能·开源·claude
天若有情6736 小时前
开源|Comfort Lang v1.0.1,一款主打清爽交互的新型命令式编程语言
python·开源·交互·编程语言·项目分享·repl·comfort lang