kkprotocol 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kkprotocol/compiler.py ADDED
@@ -0,0 +1,182 @@
1
+ # -*- coding: utf-8 -*-
2
+ """规则编译:把 ``Field``/字典规则校验并编译成解析期使用的结构。
3
+
4
+ 所有配置错误都在**编译期**暴露(可在启动时调用 ``codec.compile()``),
5
+ 而不是等到运行期解析到某帧才炸。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from collections.abc import Mapping as MappingABC
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
14
+
15
+ from .errors import FrameRuleError
16
+ from .fields import Field
17
+ from .length import _GROUP_REF_RE, _LEN_ATTRS, LengthExpr
18
+
19
+ __all__ = ["CompiledField", "compile_rules", "normalize_rule"]
20
+
21
+ _LENGTH_KINDS = (int, LengthExpr)
22
+
23
+
24
+ def normalize_rule(item: Any) -> Field:
25
+ """把 dict 规则或 Field 统一成 Field。"""
26
+ if isinstance(item, Field):
27
+ return item
28
+ if isinstance(item, MappingABC):
29
+ return Field.from_dict(dict(item))
30
+ raise FrameRuleError(
31
+ "规则项必须是 Field 或 dict,实际 {}".format(type(item).__name__)
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class CompiledField:
37
+ """编译后的字段描述,解析热路径只读它。"""
38
+
39
+ key: str
40
+ length: Optional[Union[int, LengthExpr, Callable[[Dict[str, Any]], int]]]
41
+ fmt: str
42
+ fixed: Any
43
+ group_refs: Optional[Tuple[str, ...]]
44
+ is_crc: bool
45
+ is_device: bool
46
+ desc: str = ""
47
+ struct: Optional[struct.Struct] = None
48
+ fmt_size: Optional[int] = None
49
+ byteorder: str = "big"
50
+
51
+
52
+ def _compile_length(key: str, spec: Any) -> Optional[Union[int, LengthExpr, Callable]]:
53
+ if spec is None: # 取到帧尾为止,只有分隔符定界能确定边界
54
+ return None
55
+ if callable(spec):
56
+ return spec
57
+ if isinstance(spec, bool):
58
+ raise FrameRuleError("字段 {!r} 的长度不能是 bool".format(key))
59
+ if isinstance(spec, int):
60
+ return spec
61
+ if isinstance(spec, str):
62
+ text = spec.strip()
63
+ if not text:
64
+ raise FrameRuleError("字段 {!r} 的长度为空字符串".format(key))
65
+ try:
66
+ return int(text)
67
+ except ValueError:
68
+ return LengthExpr(text)
69
+ raise FrameRuleError(
70
+ "字段 {!r} 的长度必须是 int / str / callable,实际 {}".format(key, type(spec).__name__)
71
+ )
72
+
73
+
74
+ def compile_rules(
75
+ raw_rules: Sequence[Union[Field, Dict[str, Any]]],
76
+ device_field: Optional[str] = None,
77
+ ) -> List[CompiledField]:
78
+ """校验并编译规则列表;任何不一致都抛 :class:`FrameRuleError`。"""
79
+ if not raw_rules:
80
+ raise FrameRuleError("rules() 返回空规则,至少需要一个字段")
81
+
82
+ compiled: List[CompiledField] = []
83
+ seen: List[str] = []
84
+
85
+ for item in raw_rules:
86
+ field = normalize_rule(item)
87
+ key = field.key
88
+ if not isinstance(key, str) or not key:
89
+ raise FrameRuleError("规则缺少合法的 key: {!r}".format(key))
90
+ if key in seen:
91
+ raise FrameRuleError("字段 key 重复: {!r}".format(key))
92
+ fmt = field.fmt
93
+ if not isinstance(fmt, str):
94
+ raise FrameRuleError("字段 {!r} 的 fmt 必须是字符串".format(key))
95
+
96
+ st: Optional[struct.Struct] = None
97
+ fmt_size: Optional[int] = None
98
+ if fmt:
99
+ try:
100
+ st = struct.Struct(fmt)
101
+ except struct.error as exc:
102
+ raise FrameRuleError("字段 {!r} 的 fmt {!r} 非法: {}".format(key, fmt, exc)) from exc
103
+ if st.size <= 0:
104
+ raise FrameRuleError("字段 {!r} 的 fmt {!r} 长度为 0".format(key, fmt))
105
+ if len(st.unpack(b"\x00" * st.size)) != 1:
106
+ raise FrameRuleError("字段 {!r} 的 fmt {!r} 必须只解析出一个值".format(key, fmt))
107
+ fmt_size = st.size
108
+
109
+ length = _compile_length(key, field.length)
110
+ if isinstance(length, int):
111
+ if length < 0:
112
+ raise FrameRuleError("字段 {!r} 长度为负数".format(key))
113
+ if length == 0 and st is not None:
114
+ raise FrameRuleError("字段 {!r} 为零长度,不能声明 fmt".format(key))
115
+ if fmt_size is not None and fmt_size != length:
116
+ raise FrameRuleError(
117
+ "字段 {!r} 声明长度 {} 与 fmt {!r} 的字节数 {} 不一致".format(
118
+ key, length, fmt, fmt_size
119
+ )
120
+ )
121
+ elif isinstance(length, LengthExpr):
122
+ for ref_key, attr in length.refs:
123
+ if ref_key not in seen:
124
+ raise FrameRuleError(
125
+ "字段 {!r} 的长度表达式引用了不存在或尚未解析的字段 {!r}"
126
+ "(只能引用前面的字段)".format(key, ref_key)
127
+ )
128
+ if attr != "val" and attr not in _LEN_ATTRS:
129
+ raise FrameRuleError(
130
+ "字段 {!r} 的长度表达式引用了不支持的属性 {!r}".format(key, attr)
131
+ )
132
+
133
+ group_refs: Optional[Tuple[str, ...]] = None
134
+ is_crc = bool(field.crc)
135
+ if field.group is not None:
136
+ group = field.group
137
+ if not isinstance(group, str) or not group:
138
+ raise FrameRuleError("字段 {!r} 的 group 必须是非空字符串".format(key))
139
+ refs = tuple(m.group(1) for m in _GROUP_REF_RE.finditer(group))
140
+ if not refs:
141
+ raise FrameRuleError("字段 {!r} 的 group 未包含任何 {{字段}} 引用".format(key))
142
+ for ref_key in refs:
143
+ if ref_key not in seen:
144
+ raise FrameRuleError(
145
+ "字段 {!r} 的 group 引用了不存在或尚未解析的字段 {!r}".format(key, ref_key)
146
+ )
147
+ group_refs = refs
148
+ is_crc = True
149
+ elif is_crc:
150
+ if not seen:
151
+ raise FrameRuleError("校验字段 {!r} 之前没有可参与校验的内容".format(key))
152
+ group_refs = tuple(seen)
153
+
154
+ compiled.append(
155
+ CompiledField(
156
+ key=key,
157
+ length=length,
158
+ fmt=fmt,
159
+ fixed=field.fixed,
160
+ group_refs=group_refs,
161
+ is_crc=is_crc,
162
+ is_device=(device_field is not None and key == device_field),
163
+ desc=field.desc,
164
+ struct=st,
165
+ fmt_size=fmt_size,
166
+ byteorder="little" if fmt.startswith("<") else "big",
167
+ )
168
+ )
169
+ seen.append(key)
170
+
171
+ if device_field is not None and device_field not in seen:
172
+ raise FrameRuleError(
173
+ "device_field={!r} 不在规则里,可用字段: {}".format(device_field, seen)
174
+ )
175
+ for cf in compiled:
176
+ if cf.is_device and cf.fixed is not None:
177
+ raise FrameRuleError(
178
+ "设备号字段 {!r} 不能同时声明 fixed(设备号由 device_id/自动学习决定)".format(
179
+ cf.key
180
+ )
181
+ )
182
+ return compiled
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ """贡献协议集(contrib):从网上收集的真实通讯协议,用来压测 kkprotocol 的设计健壮性。
3
+
4
+ 这些模块**不会**出现在 ``kkprotocol.__init__`` 顶层导出里,只是包内模块,
5
+ 按需 ``from kkprotocol.contrib import dlt645`` 即可得到。每个模块都尽量用当前
6
+ 的声明式 ``FrameSpec`` / ``Protocol`` / ``FrameBuilder`` 落地,并显式标注哪些地方
7
+ 暴露了设计上的纰漏(见各模块 docstring 与 tests/test_contrib_protocols.py)。
8
+ """
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Art-Net(舞台灯光 UDP 协议,这里取它的以太网头部做压测)。
3
+
4
+ Art-Net 报文以 **8 字节字符串 magic** ``"Art-Net\\0"`` 开头,然后是操作码、版本号等::
5
+
6
+ "Art-Net\0" OpCode(2, 小端) ProtVer(2, 大端) TalkToMe(1) Priority(1) ...
7
+
8
+ 要点(压测点):
9
+
10
+ * 同步标记是**多字节字符串** ``b"Art-Net\\0"``,而不是单字节标志。
11
+ 库的长度定界支持把 ``sync`` 配成多字节(``AbstractFrameCodec.sync = b"Art-Net\\x00"``),
12
+ 这点设计是够用的——这正好补上“DelimiterFraming.start 只支持单字节”的反例:
13
+ **多字节 magic 走长度定界的 sync 就行,只有分隔符定界才卡在单字节**。
14
+ * 这里是 UDP,长度由 IP 层给出,所以帧内没有长度定界字段,仅靠固定头部长度 + magic。
15
+
16
+ 说明:只解析前 14 字节的 ArtPoll 头部(magic + opcode + version + talktome + priority),
17
+ 足以验证多字节同步;完整 ArtDmx 的 512 字节数据段不在此建模。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from ..codec import AbstractFrameCodec, LENGTH_FRAMING
22
+ from ..spec import Field
23
+
24
+
25
+ class ArtNetCodec(AbstractFrameCodec):
26
+ # 多字节同步标记:这是“长度定界”支持、但“分隔符定界”的 start/end 不支持的能力
27
+ sync = b"Art-Net\x00"
28
+ framing = LENGTH_FRAMING
29
+ max_frame_size = 1024
30
+
31
+ def rules(self):
32
+ return [
33
+ Field.raw("magic", 8, fixed=b"Art-Net\x00"),
34
+ Field.u16("opcode", little_endian=True),
35
+ Field.u16("version", little_endian=False),
36
+ Field.u8("talktome"),
37
+ Field.u8("priority"),
38
+ ]
39
+
40
+ def crc_imp(self, data):
41
+ return 0
42
+
43
+
44
+ def build_artpoll(version: int = 14, talktome: int = 0, priority: int = 0) -> bytes:
45
+ """组一条最小的 ArtPoll 头部(操作码 0x2000)。"""
46
+ return (
47
+ b"Art-Net\x00"
48
+ + (0x2000).to_bytes(2, "little")
49
+ + version.to_bytes(2, "big")
50
+ + bytes([talktome, priority])
51
+ )
52
+
53
+
54
+ def codec() -> AbstractFrameCodec:
55
+ return ArtNetCodec()
@@ -0,0 +1,110 @@
1
+ # -*- coding: utf-8 -*-
2
+ """BACnet MS/TP(楼宇自控,RS-485 令牌环)。
3
+
4
+ 帧结构(长度定界,固定 2 字节前导 55 FF):::
5
+
6
+ 55 FF 类型(1) 目的(1) 源(1) 长度(2, 大端)
7
+ [令牌帧到此为止]
8
+ 头CRC(1, 覆盖 类型/目的/源/长度)
9
+ [数据帧另有] DATA(长度) 数据CRC(2, 覆盖 DATA)
10
+
11
+ 要点(压测点 —— 这是本次最关键的“设计缺口”样本):
12
+
13
+ * 一帧里**有两个校验和**(头 CRC8 + 体 CRC16),且头 CRC 在帧中部、体 CRC 在帧尾;
14
+ * 解析侧:库会对**所有**标 ``crc=True`` 的字段逐个校验,所以两个 CRC **都能验**
15
+ (本模块用 ``to_codec`` 解析验证);
16
+ * **组帧侧**:``FrameBuilder`` 的 ``FrameSpec.checksum`` 只能声明**一个**校验和,
17
+ 数据帧(头 + 体两个 CRC)目前**无法用 builder 一次组出来**——这是真实缺口
18
+ (见 ``build_data`` 只能手工构造)。建议未来支持“一帧多校验和 / 按字段独立计算”。
19
+ * 令牌帧(长度=0)没有数据也没有体 CRC,和结构不同的数据帧属于“同一字节流里的多形态”
20
+ —— 这里用两份 spec(TOKEN / DATA)分别解析,和 example_modbus_rtu_multi 的多响应分派是同一类问题。
21
+
22
+ 校验多项式说明:BACnet 头 CRC8 用 0x07、体 CRC16 用 0x1021(初值 0xFFFF)属非反射族;
23
+ 离线无法用公开文档的 0x55 / 0xF0B8 残差反推到标准算法(文档示例自洽性存疑),但库只
24
+ 比对“校验值是否一致”,因此这里用自洽的 CRC 建模双校验结构,重点测库的解析能力。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from ..crc import crc16_ccitt, crc8
29
+ from ..spec import ChecksumRule, Field, FrameSpec
30
+
31
+ PRE1 = 0x55
32
+ PRE2 = 0xFF
33
+
34
+
35
+ def _header_crc(ftype: int, dst: int, src: int, length: int) -> int:
36
+ head = bytes([ftype, dst, src]) + length.to_bytes(2, "big")
37
+ return crc8(head, poly=0x07, init=0xFF)
38
+
39
+
40
+ def _data_crc(data: bytes) -> int:
41
+ return crc16_ccitt(data, init=0xFFFF)
42
+
43
+
44
+ SPEC_TOKEN = FrameSpec(
45
+ fields=[
46
+ Field.u8("pre1", fixed=PRE1),
47
+ Field.u8("pre2", fixed=PRE2),
48
+ Field.u8("ftype"),
49
+ Field.u8("dst", desc="目的 MAC 地址(设备号)"),
50
+ Field.u8("src"),
51
+ Field.u16("length", little_endian=False),
52
+ Field.checksum("hcrc", 1, group="{ftype}{dst}{src}{length}"),
53
+ ],
54
+ device="dst",
55
+ checksum=ChecksumRule("hcrc", lambda g: crc8(g, poly=0x07, init=0xFF)),
56
+ max_frame_size=8,
57
+ desc="BACnet MS/TP 令牌帧",
58
+ )
59
+
60
+ SPEC_DATA = FrameSpec(
61
+ fields=[
62
+ Field.u8("pre1", fixed=PRE1),
63
+ Field.u8("pre2", fixed=PRE2),
64
+ Field.u8("ftype"),
65
+ Field.u8("dst", desc="目的 MAC 地址(设备号)"),
66
+ Field.u8("src"),
67
+ Field.u16("length", little_endian=False),
68
+ Field.checksum("hcrc", 1, group="{ftype}{dst}{src}{length}"),
69
+ Field.raw("data", "{length.val}"),
70
+ Field.checksum("dcrc", 2, fmt="!H", group="{data}"),
71
+ ],
72
+ device="dst",
73
+ # 一帧双校验和:头 CRC8(覆盖类型/目的/源/长度)+ 体 CRC16(仅覆盖 DATA)。
74
+ # 解析侧已支持一帧多校验和(每个字段按各自 impl 独立校验);组帧侧仍只回填第一个,
75
+ # 所以数据帧组帧走手工 build_data(见下方)。
76
+ checksum=[
77
+ ChecksumRule("hcrc", lambda g: crc8(g, poly=0x07, init=0xFF)),
78
+ ChecksumRule("dcrc", lambda g: crc16_ccitt(g, init=0xFFFF)),
79
+ ],
80
+ max_frame_size=512,
81
+ desc="BACnet MS/TP 数据帧",
82
+ )
83
+
84
+
85
+ def protocol_token(device_id=None, enforce=False):
86
+ from ..builder import Protocol
87
+ return Protocol(SPEC_TOKEN, device_id=device_id, enforce_device_id=enforce)
88
+
89
+
90
+ def protocol_data(device_id=None, enforce=False):
91
+ from ..builder import Protocol
92
+ return Protocol(SPEC_DATA, device_id=device_id, enforce_device_id=enforce)
93
+
94
+
95
+ def build_token(ftype: int, dst: int, src: int) -> bytes:
96
+ """组一条令牌帧(长度=0,无数据、无体 CRC)。"""
97
+ length = 0
98
+ head = bytes([PRE1, PRE2, ftype, dst, src]) + length.to_bytes(2, "big")
99
+ return head + bytes([_header_crc(ftype, dst, src, length)])
100
+
101
+
102
+ def build_data(ftype: int, dst: int, src: int, data: bytes) -> bytes:
103
+ """手工组一条数据帧(同时算头 CRC8 与体 CRC16)。
104
+
105
+ 注意:这条只能手工组,因为 FrameBuilder 只支持单校验和,组不出“头+体双 CRC”。
106
+ """
107
+ length = len(data)
108
+ head = bytes([PRE1, PRE2, ftype, dst, src]) + length.to_bytes(2, "big")
109
+ body = head + bytes([_header_crc(ftype, dst, src, length)]) + data
110
+ return body + _data_crc(data).to_bytes(2, "big")
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """ccTalk 货币交易外设串行协议(硬币/纸币识别器、找零器,TTL 多-drop 总线)。
3
+
4
+ 帧结构(长度定界,**没有帧头/帧尾定界符**):::
5
+
6
+ [目的地址] [数据长度] [源地址] [命令头] [DATA...] [校验和]
7
+
8
+ 校验和:所有字节(含校验和)累加后 mod 256 == 0,因此校验和字节 = ``(256 - 前 N 字节和) % 256``。
9
+
10
+ 要点(压测点):
11
+
12
+ * **完全没有帧头/帧尾定界符**,也没有同步字节——帧边界只能靠“数据长度字段”推算;
13
+ * 一旦流错位(丢/多一个字节),后续所有帧都会整体偏移,**无法靠同步锚点恢复**
14
+ (这与 DLT645/CJ/T188 有固定 0x68 能重同步形成对比)。
15
+ * 校验和是“凑零和”式,不是 CRC。
16
+
17
+ 暴露的设计缺口:库的长度定界依赖一个同步锚点来寻找帧起点;ccTalk 这种“无锚点”
18
+ 协议在错位后恢复能力弱,属于协议本身的天花板,但库也应提供“无同步、纯长度推算”
19
+ 的明确模式(目前靠 ``sync=None`` 退化为“从缓冲区起点直接读”,错位即全盘皆错)。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from ..builder import FrameBuilder, Protocol
24
+ from ..spec import ChecksumRule, Field, FrameSpec, LengthRule
25
+
26
+ SPEC = FrameSpec(
27
+ fields=[
28
+ Field.u8("dest", desc="目的地址(设备号)"),
29
+ Field.u8("dlen"),
30
+ Field.u8("source"),
31
+ Field.u8("header"),
32
+ Field.raw("data", "{dlen.val}"),
33
+ Field.checksum("cs", 1, group="{dest}{dlen}{source}{header}{data}"),
34
+ ],
35
+ payload="data",
36
+ # ccTalk 长度字节 = 数据字节数(不含源/头/校验),圈住数据字段自身
37
+ length=LengthRule("dlen", span=("data", "data")),
38
+ checksum=ChecksumRule("cs", lambda group: (-sum(group)) & 0xFF),
39
+ device="dest",
40
+ max_frame_size=260,
41
+ desc="ccTalk",
42
+ )
43
+
44
+ _builder = FrameBuilder(SPEC)
45
+
46
+
47
+ def protocol(device_id: int = None, enforce_device_id: bool = False) -> Protocol:
48
+ return Protocol(SPEC, device_id=device_id, enforce_device_id=enforce_device_id)
49
+
50
+
51
+ def build(dest: int, source: int, header: int, data: bytes = b"") -> bytes:
52
+ """组一条 ccTalk 报文(长度与校验和自动填)。"""
53
+ return _builder.build_raw(None, data, dest=dest, source=source, header=header)
@@ -0,0 +1,57 @@
1
+ # -*- coding: utf-8 -*-
2
+ """CJ/T188-2004 户用计量仪表数据传输技术条件(水/气/热表,RS-485)。
3
+
4
+ 帧结构(长度定界,同步字节 0x68):::
5
+
6
+ 68 T(1) A0..A6(7) C L DATA(L) CS(1, 累加和) 16
7
+
8
+ 与 DLT645 相比:
9
+
10
+ * 只有一个 ``0x68``(地址 7 字节后没有第二个 0x68),结构更干净;
11
+ * 数据域**不做** +0x33 变换;
12
+ * CS 同样是 0x68 到 DATA 末尾的累加和。
13
+
14
+ 用来验证:单固定字节同步 + 长度字段(span 圈数据)+ 和校验,是最“标准”的长度定界样本。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from ..builder import FrameBuilder, Protocol
19
+ from ..crc import sum8
20
+ from ..spec import ChecksumRule, Field, FrameSpec, LengthRule
21
+
22
+ START = 0x68
23
+ END = 0x16
24
+
25
+
26
+ SPEC = FrameSpec(
27
+ fields=[
28
+ Field.u8("start", fixed=START),
29
+ Field.u8("type"),
30
+ Field.raw("addr", 7, desc="表号(低字节在前,BCD)"),
31
+ Field.u8("ctrl"),
32
+ Field.u8("len"),
33
+ Field.raw("data", "{len.val}"),
34
+ Field.checksum("cs", 1, group="{start}{type}{addr}{ctrl}{len}{data}"),
35
+ Field.u8("end", fixed=END),
36
+ ],
37
+ payload="data",
38
+ length=LengthRule("len", span=("data", "data")),
39
+ checksum=ChecksumRule("cs", sum8),
40
+ device="addr",
41
+ max_frame_size=128,
42
+ desc="CJ/T188-2004",
43
+ )
44
+
45
+ _builder = FrameBuilder(SPEC)
46
+
47
+
48
+ def protocol(device_id: bytes = None, enforce_device_id: bool = False) -> Protocol:
49
+ return Protocol(SPEC, device_id=device_id, enforce_device_id=enforce_device_id)
50
+
51
+
52
+ def build_request(addr: bytes, ctrl: int = 0x01, meter_type: int = 0x10,
53
+ data: bytes = b"") -> bytes:
54
+ """组一条读表请求(默认读数据 CTR_0=0x01,冷水表 T=0x10)。"""
55
+ if len(addr) != 7:
56
+ raise ValueError("CJ/T188 地址必须 7 字节")
57
+ return _builder.build_raw(None, data, addr=addr, ctrl=ctrl, type=meter_type)
@@ -0,0 +1,81 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DL/T645-2007 多功能电能表通信协议(RS-485,国内电表常见)。
3
+
4
+ 帧结构(长度定界,同步字节 0x68)::
5
+
6
+ 68 A0..A5(6) 68 C L DATA(L, 每字节 +0x33) CS(1, 累加和) 16
7
+
8
+ 要点(也是用来压测的点):
9
+
10
+ * 帧首/帧尾是固定字节 ``0x68`` / ``0x16``,地址之后还有一个 ``0x68`` —— 结构化帧头里
11
+ 重复出现同步字节;若流错位落在中间的 0x68 上,只能靠长度 + 校验否证后重同步
12
+ (库当前只支持**单字节**同步锚点,无法表达“68 + 地址 + 68”这种结构化帧头)。
13
+ * 数据域发送端逐字节 ``+0x33``、接收端 ``-0x33``(传输层字节变换,不是加密);
14
+ 这意味着数据里会出现 0x68 / 0x16 / 0x33,库没有“逐字节变换”原语,只能在
15
+ 组帧/解析后手动 add33 / sub33。
16
+ * CS 是从第一个 0x68 到 DATA 末尾所有字节的累加和(取低 8 位)。
17
+ * 长度字段 ``L`` 只等于 DATA 字节数(不是“到帧尾”),靠 ``LengthRule(span=...)`` 圈定。
18
+
19
+ 结论:库能完整组帧 + 解析(长度字段被 ``span`` 正确回填),暴露的设计缺口见上面三点。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from ..builder import FrameBuilder, Protocol
24
+ from ..crc import sum8
25
+ from ..framing import LENGTH_FRAMING
26
+ from ..spec import ChecksumRule, Field, FrameSpec, LengthRule
27
+
28
+ START = 0x68
29
+ END = 0x16
30
+
31
+
32
+ def add33(data: bytes) -> bytes:
33
+ """数据域发送端编码:逐字节 +0x33。"""
34
+ return bytes((b + 0x33) & 0xFF for b in data)
35
+
36
+
37
+ def sub33(data: bytes) -> bytes:
38
+ """数据域接收端解码:逐字节 -0x33。"""
39
+ return bytes((b - 0x33) & 0xFF for b in data)
40
+
41
+
42
+ SPEC = FrameSpec(
43
+ fields=[
44
+ Field.u8("start", fixed=START),
45
+ Field.raw("addr", 6, desc="表号(低字节在前)"),
46
+ Field.u8("mid", fixed=START),
47
+ Field.u8("ctrl"),
48
+ Field.u8("len"),
49
+ Field.raw("data", "{len.val}"),
50
+ Field.checksum("cs", 1, group="{start}{addr}{mid}{ctrl}{len}{data}"),
51
+ Field.u8("end", fixed=END),
52
+ ],
53
+ payload="data",
54
+ # 长度字段 L 只等于 DATA 字节数:用 span=("data","data") 圈住数据字段自身
55
+ # (span 左闭右闭、起点计入,所以 “data..data” = 仅数据段长度)
56
+ length=LengthRule("len", span=("data", "data")),
57
+ checksum=ChecksumRule("cs", sum8),
58
+ device="addr",
59
+ max_frame_size=64,
60
+ desc="DL/T645-2007",
61
+ )
62
+
63
+ _builder = FrameBuilder(SPEC)
64
+
65
+
66
+ def protocol(device_id: bytes = None, enforce_device_id: bool = False) -> Protocol:
67
+ """拿一个既能组指令又能解析的 Protocol。"""
68
+ return Protocol(SPEC, device_id=device_id, enforce_device_id=enforce_device_id)
69
+
70
+
71
+ def build_request(addr: bytes, ctrl: int = 0x11, data: bytes = b"") -> bytes:
72
+ """组一条请求帧。``data`` 传应用层原始字节(不含 +0x33),长度即 L。"""
73
+ if len(addr) != 6:
74
+ raise ValueError("DLT645 地址必须 6 字节")
75
+ encoded = add33(data)
76
+ return _builder.build_raw(None, encoded, addr=addr, ctrl=ctrl)
77
+
78
+
79
+ def decode_data(raw_data: bytes) -> bytes:
80
+ """把解析出来的 data 原始字节还原成应用层数据(-0x33)。"""
81
+ return sub33(raw_data)
@@ -0,0 +1,47 @@
1
+ # -*- coding: utf-8 -*-
2
+ """HDLC / PPP 风格帧(标志 0x7E,0x7D 转义异或 0x20,FCS 为 CRC-16)。
3
+
4
+ 帧结构(分隔符定界,首尾共用 0x7E):::
5
+
6
+ FLAG(0x7E) ADDRESS(1) CONTROL(1) INFO(变长) FCS(2, CRC-16) FLAG(0x7E)
7
+
8
+ 这里用来验证:
9
+
10
+ * 分隔符定界 + 转义(PPP/HDLC 标准做法)在通用引擎下能正确组帧/解析;
11
+ * 净荷里出现 0x7E / 0x7D 时被转义,不会误判帧边界;
12
+ * FCS 是一段末尾固定 2 字节的 CRC(这里用 ``crc16_x25`` 与现有 PPP 示例一致)。
13
+
14
+ 说明:真实 HDLC 的 FCS 字节序与补码约定各异,这里只验证“库的转义 + 末尾 CRC”链路,
15
+ FCS 用库内一致的 ``crc16_x25`` 大端打包,不追求与某款硬件逐字节一致。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from ..crc import crc16_x25
20
+ from ..framing import DelimiterFraming, wrap_frame
21
+ from ..spec import ChecksumRule, Field, FrameSpec
22
+
23
+ HDLC = DelimiterFraming(end=0x7E, start=0x7E, escape=0x7D, escape_xor=0x20)
24
+
25
+ SPEC = FrameSpec(
26
+ fields=[
27
+ Field.u8("address"),
28
+ Field.u8("control"),
29
+ Field.rest("info", desc="信息字段,取到帧尾 0x7E 前"),
30
+ Field.checksum("fcs", 2, fmt="!H", group="{address}{control}{info}"),
31
+ ],
32
+ framing=HDLC,
33
+ # 必须声明 ChecksumRule,否则 to_codec() 的 crc_imp 默认返回 0,FCS 会校验失败
34
+ checksum=ChecksumRule("fcs", crc16_x25),
35
+ desc="HDLC/PPP style",
36
+ )
37
+
38
+
39
+ def codec():
40
+ return SPEC.to_codec()
41
+
42
+
43
+ def build(address: int, control: int, info: bytes) -> bytes:
44
+ """组一条 HDLC 帧:先算 FCS,再整体加 0x7E 标志并转义。"""
45
+ inner = bytes([address, control]) + info
46
+ fcs = crc16_x25(inner).to_bytes(2, "big")
47
+ return wrap_frame(inner + fcs, HDLC)
@@ -0,0 +1,61 @@
1
+ # -*- coding: utf-8 -*-
2
+ """M-Bus(Meter-Bus,EN 13757-2/3 有线抄表总线)。
3
+
4
+ 长帧结构(长度定界,同步字节 0x68):::
5
+
6
+ 68 L L 68 C A CI DATA(L-3) CS(1) 16
7
+
8
+ 要点(压测点):
9
+
10
+ * 长度字段 ``L`` **重复出现两次**(68 L L 68),且 L = 控制/地址/CI + 数据 的字节数;
11
+ * CS 是第一个 0x68 到 DATA 末尾的累加和。
12
+
13
+ 暴露的设计缺口:库当前**只支持一个长度字段**自动回填,M-Bus 这种“长度字节冗余
14
+ 两次”的结构,组帧时第二份 L 无法被 builder 自动填成和第一份一样——所以这里组帧
15
+ 走手工构造(见 ``build_mbus``),解析走通用引擎(能正确切帧 + 校验)。这本身不是致命
16
+ 缺陷,但说明 builder 对“冗余长度/字段复制”没有原生支持。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from ..builder import Protocol
21
+ from ..crc import sum8
22
+ from ..spec import ChecksumRule, Field, FrameSpec
23
+
24
+ START = 0x68
25
+ END = 0x16
26
+
27
+
28
+ SPEC = FrameSpec(
29
+ fields=[
30
+ Field.u8("start", fixed=START),
31
+ Field.u8("len"),
32
+ Field.u8("len2"),
33
+ Field.u8("mid", fixed=START),
34
+ Field.u8("ctrl"),
35
+ Field.u8("addr", desc="从站地址(设备号)"),
36
+ Field.u8("ci"),
37
+ Field.raw("data", "{len.val}-3"),
38
+ Field.checksum("cs", 1, group="{start}{len}{len2}{mid}{ctrl}{addr}{ci}{data}"),
39
+ Field.u8("end", fixed=END),
40
+ ],
41
+ payload="data",
42
+ checksum=ChecksumRule("cs", sum8),
43
+ device="addr",
44
+ max_frame_size=256,
45
+ desc="M-Bus long frame",
46
+ )
47
+
48
+
49
+ def protocol(device_id: int = None, enforce_device_id: bool = False) -> Protocol:
50
+ return Protocol(SPEC, device_id=device_id, enforce_device_id=enforce_device_id)
51
+
52
+
53
+ def build_mbus(ctrl: int, addr: int, ci: int, data: bytes = b"") -> bytes:
54
+ """手工组一条 M-Bus 长帧(自动填两份相同的 L 与累加和 CS)。
55
+
56
+ 注意:这里没有走 ``FrameBuilder``,因为第二份长度字节无法被 builder 自动复制。
57
+ """
58
+ length = 3 + len(data) # C + A + CI + DATA
59
+ body = bytes([START, length, length, START, ctrl & 0xFF, addr & 0xFF, ci & 0xFF]) + data
60
+ cs = sum8(body) # 从第一个 0x68 到 DATA 末尾
61
+ return body + bytes([cs, END])