mbt-hj212

    纯 MoonBit 的 HJ 212 污染物在线监控数据传输协议实现:帧编解码、数据区解析、命令层与流式分帧,核心零依赖。

    hj212
    environmental
    pollution-monitoring
    protocol
    codec
    iot
    cli
    Download zip
    Author
    Version
    0.1.0
    License
    MIT
    Last updated
    3 hours ago
    Downloads
    3

    #mbt-hj212

    HJ 212 污染物在线监控数据传输协议的 MoonBit 实现:帧编解码、数据区解析、命令层、流式分帧,附带一个能把一条原始报文摊开成字段表的命令行工具。

    核心库零依赖,只用 MoonBit 标准库。

    ##0140QN=20240115103000123;ST=31;CN=2011;PW=123456;MN=0100...DC0;Flag=5;CP=&&DataTime=...;a34013-Rtd=1.52&&3647\r\n └┬─┘└──┘└──────────────────── 数据段 ────────────────────┘└──┘└─┘ 包头 长度 CRC 包尾

    #为什么值得单独做一个

    HJ 212 是国内污染源在线监测联网的强制标准,全国每一台数采仪都在按它往生态环境部门的平台报送数据。协议本身不难,难的是它有三处地方特别容易实现错,而且错了不会报错,只会静默丢数据:

    1. 长度字段按 UTF-8 字节数算,不是字符数。 数据区里出现一个中文(比如 Info=备用通道),按字符数算就会少 6 个字节,接收端随之错位到 CRC,整帧被丢。这个库里长度、CRC 共用同一套字节计数。
    2. CP 的取值自己也用 ; 分隔字段。 把数据段直接按 ; 切开再找 CP,数据区会被撕成碎片。必须先定位 CP=,之前的按 ; 切字段,之后的整体交给数据区。
    3. TCP 是字节流,粘包半包是常态。 一次 read 可能拿到两条半报文,也可能只拿到 ##01。分帧器必须能跨 read 保持状态。

    这三条都有对应的测试和语料,不是靠注释声明的。

    #安装

    moon add Mr-Houjie/mbt-hj212

    #用法

    #解码一条报文

    match @hj212.Frame::decode(raw) {
    Ok(frame) => {
    println(frame.segment.cn) // "2011"
    println(frame.segment.cp.get("a34013-Rtd")) // Some("1.52")
    }
    Err(error) => println(error.message()) // 直接给人看的中文描述
    }

    错误分两类,刻意分开:

    • Frame::decode 只管结构——包头、长度、CRC、包尾。任何一项不对就拒绝整帧,绝不"尽力而为"地猜。
    • Segment::validate 只管语义——MN 长度与版本是否匹配、Flag 声明分包却没带 PNUM/PNO 之类。这些不影响解码,只作为提示返回。

    DecodeError::kind() 给出稳定的错误标识("BadCrc"、"Truncated"……),适合写日志或做分支;message() 是给人看的中文描述。

    #流式分帧

    let framer = @hj212.Framer::new()
    for chunk in chunks {
    for event in framer.feed(chunk) {
    match event {
    @hj212.Good(frame) => handle(frame)
    @hj212.Bad(error, raw) => log(error, raw)
    @hj212.Noise(text) => () // 日志前缀、半包残片
    }
    }
    }

    feed 返回的是数组,一次喂入可能吐出多条帧。不足一帧的部分留在 framer.pending() 里等下一次。

    #构造报文

    let frame : @hj212.Frame = {
    segment: {
    qn: "20240115103000123",
    st: "31",
    cn: "2011",
    pw: "123456",
    mn: "010000A8900016F000169DC0",
    flag: @hj212.Flag::new(@hj212.V2017, false, true),
    pnum: None,
    pno: None,
    cp: @hj212.DataArea::new()
    ..set("DataTime", "20240115103000")
    ..set("a34013-Rtd", "1.52"),
    extra: [],
    },
    }
    println(frame.encode())

    encode 会重新生成数据段(而不是复用输入原文),所以输出是规范形式:字段顺序固定、&& 包装一定存在、CRC 与长度一定自洽。

    #命令行

    mbt-hj212 explain <报文|文件> 逐字段解释一条报文 mbt-hj212 check <报文|文件> 只校验,不合法时退出码 1 mbt-hj212 frames <报文|文件> 按 TCP 流分帧,逐条列出 mbt-hj212 encode [选项] 构造一条报文 mbt-hj212 crc <数据段> 算数据段的 CRC 与字节长度

    explain 是最好用的一个,它把一条报文摊成表,每一行都标出来源:

    $ mbt-hj212 explain '##0140QN=20240115103000123;ST=31;CN=2011;PW=123456;MN=010000A8900016F000169DC0;Flag=5;CP=&&DataTime=20240115103000;a34013-Rtd=1.52;a34013-Flag=N&&3647' 长度 140 字节(数据段) CRC 3647 校验通过 版本 HJ 212-2017 Flag 5 版本位 000001 · 不分包 · 需应答 QN 20240115103000123 2024-01-15 10:30:00.123 ST 31 大气环境污染源 CN 2011 污染物实时数据 · 双向 PW 123456 MN 010000A8900016F000169DC0 24 位 CP 3 项 DataTime 20240115103000 a34013-Rtd 1.52 a34013-Flag N

    命令行直接粘报文时末尾的 \r\n 常被终端吃掉,explain/check/frames 会自动补一次;库层保持严格,不做这种妥协。

    #语料自检

    moon run cmd/verify

    corpus/ 下的帧是文件,不是代码里的字符串常量。自检会:

    1. 合法帧必须能解码,且长度、CRC 与算法一致;
    2. 解码再编码必须与原文件字节相同(往返一致);
    3. 非法帧必须被拒绝,且错误类型要与文件名前缀一致(BadCrc__xxx.frame 就必须报 BadCrc)。

    第 3 条是这个命令的价值所在。它把"我以为它会报错"变成了可执行断言。开发过程中它抓出了两个真实缺陷——重复字段被静默接受、缺少 && 包装的 CP 被当成合法数据区。

    语料由 corpus/generate.py 生成。这个脚本不复用 MoonBit 实现,而是用 Python 重写一遍 CRC16/MODBUS,让校验码来自一套独立实现。两边算得不一样,生成阶段就会暴露。

    #适用范围与已知偏差

    关于标准附录的 CRC 示例。 HJ 212-2017 附录 A 的示例报文,数据段是 QN=20160801085857223;...;CP=&&RtdInterval=30&&,标注的校验码是 1C80。按附录 A 自己给出的算法(初值 0xFFFF、反向多项式 0xA001、无最终异或)算同一段,得到的是 0759。穷举初值、多项式、正向/反向、最终异或、多种子串切片都无法得到 0x1C80。判断是示例沿用了旧稿。

    本库以算法为准,因此会拒绝 1C80 并报出正确值 0759。这一条写在测试里(标准附录示例的校验码与算法不一致),不是悄悄绕过。

    命令表与系统编码表。 逐条对照 HJ 212-2017 表 9 与表 5 录入,没有采用现场口头叫法。例如 2011 是「污染物实时数据」(取与上传共用同一码),2041 是「设备运行时间日历史数据」而不是又一个「日数据」;ST=32 是「地表水体环境污染源」(水质),烟气是 ST=31,烟气工况是 ST=51。这三处都是常见误记,代码里有专门的回归测试盯着。

    版本号。 Flag 的 V5–V0 位:000000 是 HJ/T 212-2005,000001 是 HJ 212-2017,000010 是 HJ 212-2025。注意 2017 与 2025 两次修订把最后两位的字母换过名(2017 写作 D=分包、A=应答,2025 写作 A=分包、D=应答),位权没变。本库只按位权判断,不引用字母。

    没有做的部分。 监测因子编码表(附录 B,水/气各数百项)没有收录——那是查表数据而不是协议逻辑,塞进库只会让包变大。数据区里的因子名按不透明字符串处理,DataArea::get("a34013-Rtd") 照样能用。

    #项目结构

    types.mbt 公共类型:Version、Flag、DecodeError、Segment、Frame text.mbt 字符/字节工具:UTF-8 计数、十六进制、显示宽度对齐 crc.mbt CRC16/MODBUS field_type.mbt 数据类型表:C4、N5、N14.2、YYYY/MM/DD/hh/mm/ss/zzz data_area.mbt 数据区:&& 包装、名称=值、逗号分隔多值 segment.mbt 数据段:字段切分(先定位 CP=)、序列化、语义校验 time.mbt QN 时间戳 frame.mbt 帧编解码 + 流式分帧器 commands.mbt 命令码表(表 9) explain.mbt 逐字段解释,供 CLI 使用 corpus/ 语料与生成脚本 cmd/main/ 命令行 cmd/verify/ 语料自检

    #测试

    moon test # 20 个单元测试 moon run cmd/verify # 26 个语料用例

    #许可

    MIT

    CommandInfo

    pub(all) struct CommandInfo {
    code : String
    name : String
    direction : Direction
    note : String
    }

    CommandInfo::text

    fn CommandInfo::text(self : CommandInfo) -> String

    DataArea

    pub(all) struct DataArea {
    items : Array[DataItem]
    }

    指令参数(CP)的数据区。

    序列化时按标准约定:同项多值用 , 连接,不同项用 ; 连接。

    DataArea::get

    fn DataArea::get(self : DataArea, name : String) -> String?

    取某个字段的第一个值。

    DataArea::get_all

    fn DataArea::get_all(self : DataArea, name : String) -> Array[String]

    取某个字段的全部值。

    DataArea::has

    fn DataArea::has(self : DataArea, name : String) -> Bool

    字段是否出现。

    DataArea::length

    fn DataArea::length(self : DataArea) -> Int

    字段个数。

    DataArea::new

    fn DataArea::new() -> DataArea

    DataArea::parse

    fn DataArea::parse(text : String) -> Result[DataArea, DecodeError]

    解析数据区文本。

    接受带 &&...&& 包装的形式,也接受裸数据区(便于单独测试数据区)。

    DataArea::set

    fn DataArea::set(self : DataArea, name : String, value : String) -> Unit

    追加或覆盖一个单值字段。

    DataArea::set_values

    fn DataArea::set_values(self : DataArea, name : String, values : Array[String]) -> Unit

    追加或覆盖一个多值字段。

    DataArea::text

    fn DataArea::text(self : DataArea) -> String

    渲染成规范形式(不含 && 包装)。

    DataArea::wrapped_text

    fn DataArea::wrapped_text(self : DataArea) -> String

    渲染成报文里的 CP 取值,带 && 包装。

    DataItem

    pub(all) struct DataItem {
    name : String
    values : Array[String]
    }

    数据区中的一项:名称=值,同一项可以有多个用 , 分隔的值。

    DecodeError

    pub(all) enum DecodeError {
    MissingHeader
    BadLengthField(String)
    Truncated(Int, Int)
    BadCrc(String, String)
    BadTail(String)
    EmptySegment
    MissingField(String)
    DuplicateField(String)
    BadField(String)
    BadFieldValue(String, String)
    BadDataArea(String)
    TrailingGarbage(String)
    }

    解码失败的原因。

    结构性错误(包头、长度、CRC、包尾)一律视为失败并拒绝该帧; 字段取值是否符合行业惯例属于语义问题,交由 Segment::validate 报告, 两者刻意分开。

    DecodeError::kind

    fn DecodeError::kind(self : DecodeError) -> String

    稳定的错误码,供上层程序分支判断或写日志用。

    与 message() 的区别:message() 是给人看的中文描述,措辞可能随版本调整; kind() 是给程序看的标识,一旦发布就不再变化。

    DecodeError::message

    fn DecodeError::message(self : DecodeError) -> String

    Direction

    pub(all) enum Direction {
    Down
    Up
    Both
    }

    Direction::label

    fn Direction::label(self : Direction) -> String

    FieldType

    pub(all) enum FieldType {
    Chars(Int)
    Number(Int, Int)
    Year
    Month
    Day
    Hour
    Minute
    Second
    Millisecond
    }

    数据项的数据类型,对应标准 6.3.3.2.2 的类型定义表。

    • C4 表示最多 4 位的字符串;
    • N5 表示最多 5 位数字;
    • N14.2 表示 14 位整数与 2 位小数,带符号,最大长度 18;
    • YYYY / MM / DD / hh / mm / ss / zzz 表示时间片段。

    FieldType::check

    fn FieldType::check(self : FieldType, value : String) -> String?

    校验取值是否满足类型;满足返回 None,否则返回原因。

    FieldType::parse

    fn FieldType::parse(text : String) -> FieldType?

    解析类型字符串,例如 C4、N5、N14.2、YYYY。

    FieldType::text

    fn FieldType::text(self : FieldType) -> String

    把类型渲染回字符串形式。

    Flag

    pub(all) struct Flag {
    raw : Int
    }

    报文的 Flag 字段,一个 8 位无符号整数。

    位布局(高位到低位):V5 V4 V3 V2 V1 V0 | 分包位 | 应答位

    • V5..V0:标准版本号,000000 = 2005 版,000001 = 2017 版,000010 = 2025 版;
    • bit1:数据段是否携带 PNUM / PNO 分包序号;
    • bit0:本条命令是否需要应答。

    注意:这两位的字母在两次修订里换过名。2017 版写作 D(分包) 与 A(应答),2025 版写作 A(分包)与 D(应答)。位权没有变, 变的只是字母,这也是最容易抄错的一处;本库只按位权判断,不引用字母。

    例:Flag=9 即 00001001,表示 2025 版、不分包、需要应答。

    Flag::from_raw

    fn Flag::from_raw(raw : Int) -> Flag

    Flag::is_split

    fn Flag::is_split(self : Flag) -> Bool

    Flag::needs_ack

    fn Flag::needs_ack(self : Flag) -> Bool

    Flag::new

    fn Flag::new(version : Version, split : Bool, ack : Bool) -> Flag

    Flag::text

    fn Flag::text(self : Flag) -> String

    Flag::version

    fn Flag::version(self : Flag) -> Version

    Frame

    pub(all) struct Frame {
    segment : Segment
    }

    一条完整的报文。

    Frame::check

    fn Frame::check(text : String) -> Result[Int, DecodeError]

    只做校验,成功时返回数据段长度。

    Frame::decode

    fn Frame::decode(text : String) -> Result[Frame, DecodeError]

    解码一条报文。

    严格校验:包头、长度、CRC、包尾全部通过才算成功。字段取值范围是否 符合行业惯例不在这一步判断,交给 Segment::validate。

    Frame::encode

    fn Frame::encode(self : Frame) -> String

    编码一条报文。

    数据段由字段重新生成(而不是复用输入原文),因此输出是规范形式, 空格、字段顺序、&& 包装都会被归一化。

    Frame::explain

    fn Frame::explain(self : Frame) -> String

    一条报文的逐字段解释。

    Frame::summary

    fn Frame::summary(self : Frame) -> String

    一条报文的单行摘要,适合日志与批量扫描。

    FrameEvent

    pub(all) enum FrameEvent {
    Good(Frame)
    Bad(DecodeError, String)
    Noise(String)
    }

    流式分帧的结果。

    Framer

    pub(all) struct Framer {
    buffer : String
    }

    增量分帧器。

    现场设备按 TCP 流发送,一次 read 可能拿到半条报文,也可能拿到三条半。 分帧器负责在任意切分点保持状态:喂进去一段文本,吐出这段文本里能确认的 全部事件,剩下的留在缓冲区里等下一段。

    Framer::feed

    fn Framer::feed(self : Framer, chunk : String) -> Array[FrameEvent]

    喂入一段文本,返回能确认的事件。

    Framer::new

    fn Framer::new() -> Framer

    Framer::pending

    fn Framer::pending(self : Framer) -> Int

    缓冲区里尚未成帧的字节数。

    QnTime

    pub(all) struct QnTime {
    year : Int
    month : Int
    day : Int
    hour : Int
    minute : Int
    second : Int
    millis : Int
    }

    QnTime::parse

    fn QnTime::parse(qn : String) -> QnTime?

    QnTime::text

    fn QnTime::text(self : QnTime) -> String

    渲染成 YYYY-MM-DD hh:mm:ss.zzz。

    QnTime::to_qn

    fn QnTime::to_qn(self : QnTime) -> String

    由分段时间构造 QN。

    Segment

    pub(all) struct Segment {
    qn : String
    st : String
    cn : String
    pw : String
    mn : String
    flag : Flag
    pnum : Int?
    pno : Int?
    cp : DataArea
    extra : Array[DataItem]
    }

    数据段:一条命令的固定头部加上指令参数。

    字段顺序按标准固定为 QN;ST;CN;PW;MN;Flag;PNUM;PNO;CP。 pnum / pno 只在 Flag 的 D 位为 1 时出现;extra 用来保留 现场设备自行追加的扩展字段,保证解码后再编码不会丢信息。

    Segment::parse

    fn Segment::parse(text : String) -> Result[Segment, DecodeError]

    Segment::text

    fn Segment::text(self : Segment) -> String

    渲染成规范的数据段文本。

    Segment::validate

    fn Segment::validate(self : Segment) -> Array[String]

    语义检查。

    结构性问题在 parse 阶段就会失败;这里报的是「能解析但不符合行业惯例」 的情况,例如 MN 长度与版本不匹配、Flag 声明分包却没有包号。 返回空数组表示没有发现问题。

    Version

    pub(all) enum Version {
    V2005
    V2017
    V2025
    Unknown(Int)
    }

    标准版本。

    版本号编码在报文 Flag 字段的 V5–V0 位里。现场设备并不统一: 2005 版与 2017 版的数采仪可能挂在同一条链路上,因此解析器 必须读完 Flag 才知道该用哪套字段规则去解释这条报文。

    Version::code

    fn Version::code(self : Version) -> Int

    Version::from_code

    fn Version::from_code(code : Int) -> Version

    Version::label

    fn Version::label(self : Version) -> String

    all_commands

    fn all_commands() -> Array[CommandInfo]

    全部已登记的命令,顺序与标准表 9 一致。

    command_group

    fn command_group(code : String) -> String

    命令码所属的段。

    command_name

    fn command_name(code : String) -> String?

    命令码的中文名,查不到时返回 None。

    crc16_bytes

    fn crc16_bytes(data : Array[Byte]) -> Int

    计算一段字节的 CRC16。

    crc16_hex

    fn crc16_hex(text : String) -> String

    计算 CRC 并格式化成报文里的 4 位大写十六进制。

    crc16_text

    fn crc16_text(text : String) -> Int

    计算一段文本的 CRC16(按 UTF-8 字节)。

    decode_all

    fn decode_all(text : String) -> Array[FrameEvent]

    一次性解码多帧文本,返回全部事件。

    lookup_command

    fn lookup_command(code : String) -> CommandInfo?

    按命令码查表。

    utf8_len

    fn utf8_len(s : String) -> Int

    字符串的 UTF-8 字节数。

    报文的长度字段按字节计,且 CRC 也按字节计算;两者必须使用同一套计数, 否则含中文的数据区会算错。