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/__init__.py +105 -0
- kkprotocol/builder.py +1005 -0
- kkprotocol/codec.py +804 -0
- kkprotocol/compiler.py +182 -0
- kkprotocol/contrib/__init__.py +8 -0
- kkprotocol/contrib/artnet.py +55 -0
- kkprotocol/contrib/bacnet_mstp.py +110 -0
- kkprotocol/contrib/cctalk.py +53 -0
- kkprotocol/contrib/cjt188.py +57 -0
- kkprotocol/contrib/dlt645.py +81 -0
- kkprotocol/contrib/hdlc.py +47 -0
- kkprotocol/contrib/mbus.py +61 -0
- kkprotocol/contrib/modbus_ascii.py +59 -0
- kkprotocol/contrib/nmea.py +59 -0
- kkprotocol/contrib/slip.py +38 -0
- kkprotocol/contrib/xmodem.py +74 -0
- kkprotocol/crc.py +103 -0
- kkprotocol/errors.py +48 -0
- kkprotocol/example_device.py +186 -0
- kkprotocol/example_modbus_rtu.py +176 -0
- kkprotocol/example_modbus_rtu_multi.py +391 -0
- kkprotocol/fields.py +167 -0
- kkprotocol/frame.py +114 -0
- kkprotocol/frame_codec.py +50 -0
- kkprotocol/framing.py +190 -0
- kkprotocol/length.py +151 -0
- kkprotocol/pack.py +222 -0
- kkprotocol/py.typed +0 -0
- kkprotocol/spec.py +494 -0
- kkprotocol-0.1.0.dist-info/METADATA +1015 -0
- kkprotocol-0.1.0.dist-info/RECORD +34 -0
- kkprotocol-0.1.0.dist-info/WHEEL +5 -0
- kkprotocol-0.1.0.dist-info/licenses/LICENSE +21 -0
- kkprotocol-0.1.0.dist-info/top_level.txt +1 -0
kkprotocol/codec.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""编解码器基类:自适应定界 + 异步接口 + 设备号学习。
|
|
3
|
+
|
|
4
|
+
子类只需要提供两样东西::
|
|
5
|
+
|
|
6
|
+
class MyCodec(AbstractFrameCodec):
|
|
7
|
+
framing = LENGTH_FRAMING # 或 DelimiterFraming(...) / AUTO_FRAMING
|
|
8
|
+
def rules(self): return [ Field.u8("header", fixed=0x55), ... ]
|
|
9
|
+
def crc_imp(self, data): return crc16_modbus(data)
|
|
10
|
+
|
|
11
|
+
三种定界方式:
|
|
12
|
+
|
|
13
|
+
* ``LENGTH_FRAMING``(默认):按长度字段切帧,不需要转义。
|
|
14
|
+
* ``DelimiterFraming(...)``:按分隔符切帧,自动处理字节转义;长度字段退化为校验项。
|
|
15
|
+
* ``AUTO_FRAMING`` + ``delimiter=DelimiterFraming(...)``:先用长度切帧试探,
|
|
16
|
+
失败再试分隔符,命中后锁定(``lock_framing=False`` 可关闭锁定)。
|
|
17
|
+
|
|
18
|
+
线程/协程安全:**一个连接一个实例**。``feed()`` 是同步纯内存操作;
|
|
19
|
+
``feed_async()`` 在大块数据时把解析丢到线程池,避免阻塞事件循环;
|
|
20
|
+
``read_frames()`` / ``iter_frames()`` 直接对接 asyncio 流。
|
|
21
|
+
|
|
22
|
+
帧首不是固定帧头、而是**设备号**(设备号首次未知)时的处理::
|
|
23
|
+
|
|
24
|
+
class MyCodec(AbstractFrameCodec):
|
|
25
|
+
device_field = "dev_id" # 声明哪一个是设备号
|
|
26
|
+
auto_learn_device = True # 首帧/注册包学到就记住
|
|
27
|
+
lock_device_after_learn = True # 学到即固定(不符的帧按脏数据丢)
|
|
28
|
+
sync_from_device = True # 学到的设备号兼作重同步锚点
|
|
29
|
+
preempt_on_sync = True # 假长度堵住后续真帧时用锚点切断
|
|
30
|
+
idle_timeout = 5.0 # 空闲超时丢半包(read_frames)
|
|
31
|
+
|
|
32
|
+
生命周期:**未知 → 学到 → 固定成锚点**。未知阶段没有任何内容可依,
|
|
33
|
+
从帧中间接入只能等声明的长度凑够后由 CRC 否证;学到之后设备号本身
|
|
34
|
+
就是最强的同步字,重同步从"逐字节试探"变成"一次跳转"。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
from abc import ABCMeta, abstractmethod
|
|
41
|
+
from dataclasses import dataclass, replace
|
|
42
|
+
from typing import (
|
|
43
|
+
Any,
|
|
44
|
+
AsyncIterable,
|
|
45
|
+
AsyncIterator,
|
|
46
|
+
Callable,
|
|
47
|
+
Dict,
|
|
48
|
+
List,
|
|
49
|
+
Optional,
|
|
50
|
+
Sequence,
|
|
51
|
+
Tuple,
|
|
52
|
+
Union,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
from .compiler import CompiledField, compile_rules
|
|
56
|
+
from .errors import FrameCodecError, FrameInvalid, FrameRuleError
|
|
57
|
+
from .fields import Field
|
|
58
|
+
from .frame import FieldValue, Frame
|
|
59
|
+
from .framing import (
|
|
60
|
+
AUTO_FRAMING,
|
|
61
|
+
DELIMITER_FRAMING,
|
|
62
|
+
FRAMING_MODES,
|
|
63
|
+
LENGTH_FRAMING,
|
|
64
|
+
DelimiterFraming,
|
|
65
|
+
find_end_delimiter,
|
|
66
|
+
unescape_bytes,
|
|
67
|
+
)
|
|
68
|
+
from .length import LengthExpr
|
|
69
|
+
|
|
70
|
+
__all__ = ["AbstractFrameCodec", "CodecStats"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class CodecStats:
|
|
75
|
+
"""运行期统计,便于接入监控告警。"""
|
|
76
|
+
|
|
77
|
+
bytes_in: int = 0
|
|
78
|
+
frames: int = 0
|
|
79
|
+
resyncs: int = 0
|
|
80
|
+
crc_errors: int = 0
|
|
81
|
+
length_mismatches: int = 0
|
|
82
|
+
device_mismatches: int = 0
|
|
83
|
+
rejected_frames: int = 0
|
|
84
|
+
dropped_bytes: int = 0
|
|
85
|
+
overflow_drops: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class _ParsedFields:
|
|
90
|
+
fields: Dict[str, FieldValue]
|
|
91
|
+
end: int
|
|
92
|
+
device_id: Any
|
|
93
|
+
device_mismatch: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _device_equal(known: Any, fv: FieldValue) -> bool:
|
|
97
|
+
"""比较已知设备号与字段值,兼容 int / bytes 两种表示。"""
|
|
98
|
+
if isinstance(known, (bytes, bytearray, memoryview)):
|
|
99
|
+
return bytes(known) == fv.raw
|
|
100
|
+
if isinstance(known, int) and not isinstance(known, bool):
|
|
101
|
+
if isinstance(fv.val, int):
|
|
102
|
+
return known == fv.val
|
|
103
|
+
return known == int.from_bytes(fv.raw, "big")
|
|
104
|
+
return known == fv.val
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AbstractFrameCodec(metaclass=ABCMeta):
|
|
108
|
+
"""TCP 流式帧解析器基类。"""
|
|
109
|
+
|
|
110
|
+
# ---- 定界 ---------------------------------------------------------------
|
|
111
|
+
framing: Union[str, DelimiterFraming] = LENGTH_FRAMING
|
|
112
|
+
delimiter: Optional[DelimiterFraming] = None
|
|
113
|
+
sync: Optional[Union[bytes, int]] = None
|
|
114
|
+
lock_framing: bool = True
|
|
115
|
+
|
|
116
|
+
# ---- 限制与策略 ---------------------------------------------------------
|
|
117
|
+
max_frame_size: int = 64 * 1024
|
|
118
|
+
max_buffer: int = 1024 * 1024
|
|
119
|
+
on_crc_error: str = "resync"
|
|
120
|
+
|
|
121
|
+
# ---- 设备号 -------------------------------------------------------------
|
|
122
|
+
device_field: Optional[str] = None
|
|
123
|
+
auto_learn_device: bool = True
|
|
124
|
+
enforce_device_id: bool = False
|
|
125
|
+
device_id_provider: Optional[Callable[[], Any]] = None
|
|
126
|
+
# 设备号只在首帧/注册包未知,之后基本固定:
|
|
127
|
+
# lock_device_after_learn=True 时,学到即锁定(等价于自动打开 enforce_device_id)
|
|
128
|
+
# sync_from_device=True 时,学到的设备号兼作重同步锚点(长度 ≥2 才启用)
|
|
129
|
+
lock_device_after_learn: bool = False
|
|
130
|
+
sync_from_device: bool = True
|
|
131
|
+
# 帧内出现同步锚点且该处已经是一个"完整且校验通过"的帧时,判定前面的候选是脏数据
|
|
132
|
+
# 并直接跳过去。长度定界下这是唯一能救回"被假长度堵住的后续真帧"的手段,
|
|
133
|
+
# 代价是若载荷里恰好出现锚点字节串 + 校验通过的帧,会被误切(概率极低)。
|
|
134
|
+
preempt_on_sync: bool = False
|
|
135
|
+
|
|
136
|
+
# ---- 异步 ---------------------------------------------------------------
|
|
137
|
+
offload_threshold: int = 256 * 1024
|
|
138
|
+
idle_timeout: Optional[float] = None
|
|
139
|
+
drop_pending_on_idle: bool = True
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ 抽象
|
|
142
|
+
@abstractmethod
|
|
143
|
+
def rules(self) -> Sequence[Union[Field, Dict[str, Any]]]:
|
|
144
|
+
"""返回字段规则列表,顺序即帧内字节顺序。推荐用 ``Field`` 字段类。"""
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def crc_imp(self, data: bytes) -> Union[int, bytes]:
|
|
149
|
+
"""校验实现,返回 int(与字段数值比较)或 bytes(与字段原始字节比较)。"""
|
|
150
|
+
raise NotImplementedError
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------ 生命周期
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self._buf = bytearray()
|
|
155
|
+
self._compiled: Optional[List[CompiledField]] = None
|
|
156
|
+
self._delimiter_spec: Optional[DelimiterFraming] = None
|
|
157
|
+
self._mode: Optional[str] = None
|
|
158
|
+
self._auto = False
|
|
159
|
+
self._sync: bytes = b""
|
|
160
|
+
# 一帧多校验和:字段 key -> 校验实现;命中即用各自算法,否则回落到 crc_imp
|
|
161
|
+
self._crc_impls: Dict[str, Callable[[bytes], Any]] = {}
|
|
162
|
+
self._device_id: Any = None
|
|
163
|
+
self._lock_obj: Optional[asyncio.Lock] = None
|
|
164
|
+
self.stats = CodecStats()
|
|
165
|
+
|
|
166
|
+
def compile(self) -> None:
|
|
167
|
+
"""提前编译并校验规则(建议启动时调用,把配置错误暴露在启动期)。"""
|
|
168
|
+
self._ensure_compiled()
|
|
169
|
+
|
|
170
|
+
def reset(self) -> None:
|
|
171
|
+
"""丢弃未完成的半包数据(连接重建时调用)。统计信息保留。"""
|
|
172
|
+
self._buf.clear()
|
|
173
|
+
|
|
174
|
+
def drop_pending(self) -> int:
|
|
175
|
+
"""丢弃缓冲区里尚未成帧的字节,返回丢弃数量。
|
|
176
|
+
|
|
177
|
+
长度定界且帧首没有固定帧头时,"从帧中间开始的数据"无法靠内容识别
|
|
178
|
+
(只能等声明的长度凑够后由 CRC 否证)。连接空闲、设备重连或人工判定
|
|
179
|
+
卡死时调用本方法,直接把这一小段脏数据丢掉,从下一个字节重新对齐。
|
|
180
|
+
"""
|
|
181
|
+
dropped = len(self._buf)
|
|
182
|
+
if dropped:
|
|
183
|
+
self._buf.clear()
|
|
184
|
+
self.stats.dropped_bytes += dropped
|
|
185
|
+
self.stats.resyncs += 1
|
|
186
|
+
return dropped
|
|
187
|
+
|
|
188
|
+
def reset_framing(self) -> None:
|
|
189
|
+
"""清除 auto 模式的探测结果,重新开始探测。"""
|
|
190
|
+
if self._auto:
|
|
191
|
+
self._mode = None
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def pending_bytes(self) -> bytes:
|
|
195
|
+
"""缓冲区中尚未消费的字节(半包内容),便于排查。"""
|
|
196
|
+
return bytes(self._buf)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def framing_mode(self) -> Optional[str]:
|
|
200
|
+
"""当前生效的定界方式;``auto`` 尚未探测成功时为 ``None``。"""
|
|
201
|
+
return self._mode
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ 设备号
|
|
204
|
+
@property
|
|
205
|
+
def device_id(self) -> Any:
|
|
206
|
+
"""已绑定的设备号;``None`` 表示未知(此时不校验设备字段)。"""
|
|
207
|
+
return self._device_id
|
|
208
|
+
|
|
209
|
+
@device_id.setter
|
|
210
|
+
def device_id(self, value: Any) -> None:
|
|
211
|
+
self._device_id = value
|
|
212
|
+
|
|
213
|
+
def bind_device(self, device_id: Any) -> None:
|
|
214
|
+
"""绑定设备号:之后该字段会按此值校验。"""
|
|
215
|
+
self._device_id = device_id
|
|
216
|
+
self._refresh_sync()
|
|
217
|
+
|
|
218
|
+
def forget_device(self) -> None:
|
|
219
|
+
"""解绑设备号(换设备/重新注册时调用),之后可再次自动学习。"""
|
|
220
|
+
self._device_id = None
|
|
221
|
+
self._reset_sync()
|
|
222
|
+
|
|
223
|
+
def _pull_device_from_provider(self) -> None:
|
|
224
|
+
"""设备号未知时,从外部来源(注册表/缓存/DB)拉取一次。"""
|
|
225
|
+
if self._device_id is None and self.device_id_provider is not None:
|
|
226
|
+
value = self.device_id_provider()
|
|
227
|
+
if value is not None:
|
|
228
|
+
self._device_id = value
|
|
229
|
+
self._refresh_sync()
|
|
230
|
+
|
|
231
|
+
def _refresh_sync(self) -> None:
|
|
232
|
+
"""设备号已知且位于帧首时,把它当作重同步锚点。
|
|
233
|
+
|
|
234
|
+
帧首不是固定帧头而是设备号时,本来推不出任何锚点(``_sync`` 为空,
|
|
235
|
+
重同步只能逐字节试探)。但设备号通常只在首帧未知,学到后长期不变,
|
|
236
|
+
此时它比固定帧头更有辨识度,天然适合做锚点。
|
|
237
|
+
"""
|
|
238
|
+
if not self.sync_from_device or self.sync is not None:
|
|
239
|
+
return
|
|
240
|
+
compiled = self._compiled
|
|
241
|
+
if not compiled or self._mode == DELIMITER_FRAMING:
|
|
242
|
+
return
|
|
243
|
+
first = compiled[0]
|
|
244
|
+
if not first.is_device:
|
|
245
|
+
return
|
|
246
|
+
anchor = _anchor_bytes(self._device_id, first)
|
|
247
|
+
if anchor is not None and len(anchor) >= 2:
|
|
248
|
+
self._sync = anchor
|
|
249
|
+
|
|
250
|
+
def _reset_sync(self) -> None:
|
|
251
|
+
"""设备号被解绑后,把锚点退回编译期的推导结果。"""
|
|
252
|
+
compiled = self._compiled
|
|
253
|
+
if compiled and self._mode != DELIMITER_FRAMING:
|
|
254
|
+
self._sync = self._resolve_sync(compiled[0])
|
|
255
|
+
else:
|
|
256
|
+
self._sync = b""
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------ 业务钩子
|
|
259
|
+
def on_frame(self, frame: Frame) -> bool:
|
|
260
|
+
"""帧级过滤钩子,返回 False 表示丢弃该帧(字节已被消费,不影响后续解析)。"""
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def on_device_learned(self, device_id: Any, frame: Frame) -> None:
|
|
264
|
+
"""学到新设备号时的回调(落库/日志/白名单校验等),默认不做任何事。"""
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------ 同步入口
|
|
268
|
+
def feed(self, data: Union[bytes, bytearray, memoryview]) -> List[Frame]:
|
|
269
|
+
"""喂入任意切分的数据块,返回本次能完整解析出的帧(可能为空列表)。"""
|
|
270
|
+
if isinstance(data, memoryview):
|
|
271
|
+
data = data.tobytes()
|
|
272
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
273
|
+
raise TypeError("feed() 只接受 bytes / bytearray / memoryview")
|
|
274
|
+
if not data:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
self._ensure_compiled()
|
|
278
|
+
self._pull_device_from_provider()
|
|
279
|
+
self.stats.bytes_in += len(data)
|
|
280
|
+
self._buf += data
|
|
281
|
+
return self._drain()
|
|
282
|
+
|
|
283
|
+
def filter(self, data: Union[bytes, bytearray, memoryview]) -> List[Frame]:
|
|
284
|
+
"""``feed`` 的别名,兼容旧命名。"""
|
|
285
|
+
return self.feed(data)
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------ 异步入口
|
|
288
|
+
async def feed_async(self, data: Union[bytes, bytearray, memoryview]) -> List[Frame]:
|
|
289
|
+
"""异步喂数据。
|
|
290
|
+
|
|
291
|
+
小数据块直接同步解析(解析是纯内存操作,比线程调度快几个数量级);
|
|
292
|
+
超过 ``offload_threshold`` 才丢线程池,避免单个巨帧阻塞事件循环。
|
|
293
|
+
"""
|
|
294
|
+
if isinstance(data, memoryview):
|
|
295
|
+
data = data.tobytes()
|
|
296
|
+
if len(data) < self.offload_threshold:
|
|
297
|
+
return self.feed(data)
|
|
298
|
+
if self._lock_obj is None:
|
|
299
|
+
self._lock_obj = asyncio.Lock()
|
|
300
|
+
async with self._lock_obj:
|
|
301
|
+
return await asyncio.to_thread(self.feed, data)
|
|
302
|
+
|
|
303
|
+
async def read_frames(
|
|
304
|
+
self, reader: Any, chunk_size: int = 4096
|
|
305
|
+
) -> AsyncIterator[Frame]:
|
|
306
|
+
"""从 asyncio 流(``StreamReader`` 或任何有 ``await read(n)`` 的对象)持续读帧。
|
|
307
|
+
|
|
308
|
+
设置 ``idle_timeout`` 后,读超时即视为"这条连接上没有更多数据了":
|
|
309
|
+
残留半包直接丢弃(Modbus RTU 用帧间空闲时间定界,思路相同),
|
|
310
|
+
避免一段脏数据把后续所有真帧长期堵在缓冲区里。
|
|
311
|
+
"""
|
|
312
|
+
while True:
|
|
313
|
+
if self.idle_timeout is None:
|
|
314
|
+
chunk = await reader.read(chunk_size)
|
|
315
|
+
else:
|
|
316
|
+
try:
|
|
317
|
+
chunk = await asyncio.wait_for(reader.read(chunk_size), self.idle_timeout)
|
|
318
|
+
except asyncio.TimeoutError:
|
|
319
|
+
if self.drop_pending_on_idle:
|
|
320
|
+
self.drop_pending()
|
|
321
|
+
continue
|
|
322
|
+
if not chunk:
|
|
323
|
+
return
|
|
324
|
+
for frame in await self.feed_async(chunk):
|
|
325
|
+
yield frame
|
|
326
|
+
|
|
327
|
+
async def iter_frames(self, chunks: AsyncIterable[bytes]) -> AsyncIterator[Frame]:
|
|
328
|
+
"""从任意异步字节块迭代器读帧。"""
|
|
329
|
+
async for chunk in chunks:
|
|
330
|
+
for frame in await self.feed_async(chunk):
|
|
331
|
+
yield frame
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------ 主循环
|
|
334
|
+
def _drain(self) -> List[Frame]:
|
|
335
|
+
frames: List[Frame] = []
|
|
336
|
+
pos = 0
|
|
337
|
+
size = len(self._buf)
|
|
338
|
+
while pos < size:
|
|
339
|
+
try:
|
|
340
|
+
parsed = self._parse_at(pos)
|
|
341
|
+
except FrameInvalid as exc:
|
|
342
|
+
self._handle_invalid(exc, pos)
|
|
343
|
+
if exc.crc_failed:
|
|
344
|
+
self.stats.crc_errors += 1
|
|
345
|
+
if exc.length_mismatch:
|
|
346
|
+
self.stats.length_mismatches += 1
|
|
347
|
+
self.stats.resyncs += 1
|
|
348
|
+
new_pos = pos + exc.consumed if exc.consumed else self._find_next_sync(pos + 1)
|
|
349
|
+
if new_pos <= pos: # pragma: no cover - 防御:保证解析器前进
|
|
350
|
+
new_pos = pos + 1
|
|
351
|
+
self.stats.dropped_bytes += new_pos - pos
|
|
352
|
+
pos = new_pos
|
|
353
|
+
continue
|
|
354
|
+
if parsed is None: # 半包,等下一次 feed
|
|
355
|
+
break
|
|
356
|
+
frame, consumed = parsed
|
|
357
|
+
pos += consumed
|
|
358
|
+
if frame is None: # 只消费了帧间标志位,不产生帧
|
|
359
|
+
continue
|
|
360
|
+
accepted = self._post_process(frame)
|
|
361
|
+
if accepted is None:
|
|
362
|
+
self.stats.rejected_frames += 1
|
|
363
|
+
continue
|
|
364
|
+
frames.append(accepted)
|
|
365
|
+
self.stats.frames += 1
|
|
366
|
+
|
|
367
|
+
if pos:
|
|
368
|
+
del self._buf[:pos]
|
|
369
|
+
if len(self._buf) > self.max_buffer:
|
|
370
|
+
excess = len(self._buf) - self.max_buffer
|
|
371
|
+
del self._buf[:excess]
|
|
372
|
+
self.stats.overflow_drops += 1
|
|
373
|
+
self.stats.dropped_bytes += excess
|
|
374
|
+
return frames
|
|
375
|
+
|
|
376
|
+
def _handle_invalid(self, exc: FrameInvalid, pos: int) -> None:
|
|
377
|
+
"""预留钩子:需要把重同步原因写日志时覆盖它。"""
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def _post_process(self, frame: Frame) -> Optional[Frame]:
|
|
381
|
+
if frame.device_mismatch:
|
|
382
|
+
self.stats.device_mismatches += 1
|
|
383
|
+
if not self.on_frame(frame):
|
|
384
|
+
return None
|
|
385
|
+
if (
|
|
386
|
+
self.auto_learn_device
|
|
387
|
+
and self._device_id is None
|
|
388
|
+
and self.device_field is not None
|
|
389
|
+
and frame.device_id is not None
|
|
390
|
+
):
|
|
391
|
+
self._device_id = frame.device_id
|
|
392
|
+
if self.lock_device_after_learn:
|
|
393
|
+
# 设备号只在首帧未知,学到即固定:之后设备号不符的帧直接当脏数据
|
|
394
|
+
self.enforce_device_id = True
|
|
395
|
+
self._refresh_sync()
|
|
396
|
+
frame = replace(frame, learned_device=True)
|
|
397
|
+
self.on_device_learned(frame.device_id, frame)
|
|
398
|
+
return frame
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------ 分派
|
|
401
|
+
def _parse_at(self, pos: int) -> Optional[Tuple[Optional[Frame], int]]:
|
|
402
|
+
mode = self._mode
|
|
403
|
+
if mode == LENGTH_FRAMING:
|
|
404
|
+
return self._parse_length(pos)
|
|
405
|
+
if mode == DELIMITER_FRAMING:
|
|
406
|
+
return self._parse_delimiter(pos)
|
|
407
|
+
return self._parse_auto(pos)
|
|
408
|
+
|
|
409
|
+
def _parse_auto(self, pos: int) -> Optional[Tuple[Optional[Frame], int]]:
|
|
410
|
+
"""自适应:长度定界优先,失败回退到分隔符定界,命中后锁定。"""
|
|
411
|
+
length_result: Optional[Tuple[Optional[Frame], int]] = None
|
|
412
|
+
length_error: Optional[FrameInvalid] = None
|
|
413
|
+
try:
|
|
414
|
+
length_result = self._parse_length(pos)
|
|
415
|
+
except FrameInvalid as exc:
|
|
416
|
+
length_error = exc
|
|
417
|
+
if length_result is not None:
|
|
418
|
+
self._lock_framing(LENGTH_FRAMING)
|
|
419
|
+
return length_result
|
|
420
|
+
|
|
421
|
+
delimiter_result: Optional[Tuple[Optional[Frame], int]] = None
|
|
422
|
+
delimiter_error: Optional[FrameInvalid] = None
|
|
423
|
+
try:
|
|
424
|
+
delimiter_result = self._parse_delimiter(pos)
|
|
425
|
+
except FrameInvalid as exc:
|
|
426
|
+
delimiter_error = exc
|
|
427
|
+
if delimiter_result is not None:
|
|
428
|
+
self._lock_framing(DELIMITER_FRAMING)
|
|
429
|
+
return delimiter_result
|
|
430
|
+
|
|
431
|
+
# 只要有一方表示“数据不足”,就继续等:另一方判非法可能只是因为
|
|
432
|
+
# 用错了定界方式去解读同一个半包,贸然丢数据会吞掉后面的真帧。
|
|
433
|
+
if length_error is None or delimiter_error is None:
|
|
434
|
+
return None
|
|
435
|
+
raise length_error
|
|
436
|
+
|
|
437
|
+
def _lock_framing(self, mode: str) -> None:
|
|
438
|
+
if self.lock_framing:
|
|
439
|
+
self._mode = mode
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------------ 长度定界
|
|
442
|
+
def _parse_length(self, pos: int) -> Optional[Tuple[Optional[Frame], int]]:
|
|
443
|
+
parsed = self._parse_fields(self._buf, pos, allow_rest=False)
|
|
444
|
+
if parsed is None:
|
|
445
|
+
if self.preempt_on_sync:
|
|
446
|
+
cut = self._preempt_offset(pos)
|
|
447
|
+
if cut is not None:
|
|
448
|
+
# 前面这段声称的长度把后面的真帧一起吞了:判定为脏数据,跳过去
|
|
449
|
+
raise FrameInvalid(
|
|
450
|
+
"候选帧内出现同步锚点且锚点处已是完整帧", consumed=cut - pos
|
|
451
|
+
)
|
|
452
|
+
return None
|
|
453
|
+
raw = bytes(self._buf[pos:parsed.end])
|
|
454
|
+
frame = Frame(
|
|
455
|
+
raw=raw,
|
|
456
|
+
fields=parsed.fields,
|
|
457
|
+
wire=raw,
|
|
458
|
+
framing=LENGTH_FRAMING,
|
|
459
|
+
device_id=parsed.device_id,
|
|
460
|
+
device_mismatch=parsed.device_mismatch,
|
|
461
|
+
)
|
|
462
|
+
return frame, parsed.end - pos
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------ 分隔符定界
|
|
465
|
+
def _parse_delimiter(self, pos: int) -> Optional[Tuple[Optional[Frame], int]]:
|
|
466
|
+
spec = self._delimiter_spec
|
|
467
|
+
if spec is None: # pragma: no cover - 编译期已保证
|
|
468
|
+
raise FrameRuleError("未配置 DelimiterFraming")
|
|
469
|
+
buf = self._buf
|
|
470
|
+
size = len(buf)
|
|
471
|
+
index = pos
|
|
472
|
+
|
|
473
|
+
if spec.start is not None and spec.start != spec.end:
|
|
474
|
+
# 帧头与帧尾不同:跳过帧头前的垃圾
|
|
475
|
+
while index < size and buf[index] != spec.start:
|
|
476
|
+
index += 1
|
|
477
|
+
if index >= size:
|
|
478
|
+
if size - pos > self.max_frame_size:
|
|
479
|
+
raise FrameInvalid(
|
|
480
|
+
"找不到帧头分隔符且积压超过 max_frame_size", consumed=size - pos
|
|
481
|
+
)
|
|
482
|
+
return None
|
|
483
|
+
wire_start = index
|
|
484
|
+
content_start = index + 1
|
|
485
|
+
elif spec.start is not None:
|
|
486
|
+
# 首尾共用一个标志位(PPP/HDLC):跳过前导标志
|
|
487
|
+
wire_start = index
|
|
488
|
+
while index < size and buf[index] == spec.end:
|
|
489
|
+
index += 1
|
|
490
|
+
if index >= size:
|
|
491
|
+
# 只剩标志位:把它们消费掉,等下一条数据(帧间分隔符不是帧)
|
|
492
|
+
return None, size - pos
|
|
493
|
+
content_start = index
|
|
494
|
+
else:
|
|
495
|
+
wire_start = index
|
|
496
|
+
content_start = index
|
|
497
|
+
|
|
498
|
+
end_index = find_end_delimiter(buf, content_start, spec)
|
|
499
|
+
if end_index is None:
|
|
500
|
+
if size - pos > self.max_frame_size:
|
|
501
|
+
raise FrameInvalid(
|
|
502
|
+
"分隔符帧超过 max_frame_size 仍未闭合", consumed=size - pos
|
|
503
|
+
)
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
wire_len = end_index + 1 - pos
|
|
507
|
+
if wire_len > self.max_frame_size:
|
|
508
|
+
raise FrameInvalid(
|
|
509
|
+
"分隔符帧长度 {} 超过 max_frame_size".format(wire_len), consumed=wire_len
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
content = bytes(buf[content_start:end_index])
|
|
513
|
+
try:
|
|
514
|
+
raw = unescape_bytes(content, spec)
|
|
515
|
+
except ValueError as exc: # pragma: no cover - 扫描阶段已排除悬空转义
|
|
516
|
+
raise FrameInvalid(str(exc), consumed=wire_len) from exc
|
|
517
|
+
|
|
518
|
+
parsed = self._parse_fields(raw, 0, allow_rest=True)
|
|
519
|
+
if parsed is None:
|
|
520
|
+
# 定界符已经给出确切边界,此时“数据不足”只可能是长度字段与实际不符
|
|
521
|
+
raise FrameInvalid(
|
|
522
|
+
"声明长度与分隔符区间不一致(长度字段仅作校验项)",
|
|
523
|
+
consumed=wire_len,
|
|
524
|
+
length_mismatch=True,
|
|
525
|
+
)
|
|
526
|
+
if parsed.end != len(raw):
|
|
527
|
+
raise FrameInvalid(
|
|
528
|
+
"帧尾前仍有 {} 字节未被规则消费".format(len(raw) - parsed.end),
|
|
529
|
+
consumed=wire_len,
|
|
530
|
+
length_mismatch=True,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
frame = Frame(
|
|
534
|
+
raw=raw,
|
|
535
|
+
fields=parsed.fields,
|
|
536
|
+
wire=bytes(buf[wire_start:end_index + 1]),
|
|
537
|
+
framing=DELIMITER_FRAMING,
|
|
538
|
+
device_id=parsed.device_id,
|
|
539
|
+
device_mismatch=parsed.device_mismatch,
|
|
540
|
+
)
|
|
541
|
+
return frame, wire_len
|
|
542
|
+
|
|
543
|
+
# ------------------------------------------------------------------ 字段解析
|
|
544
|
+
def _parse_fields(
|
|
545
|
+
self, buf: Union[bytes, bytearray], start: int, allow_rest: bool
|
|
546
|
+
) -> Optional[_ParsedFields]:
|
|
547
|
+
"""按规则解析一段字节;字节不足返回 None(长度定界下即半包)。
|
|
548
|
+
|
|
549
|
+
``allow_rest`` 为 True 时允许 ``length=None`` 的“剩余长度”字段
|
|
550
|
+
(只有分隔符定界能给出确定的帧边界)。
|
|
551
|
+
"""
|
|
552
|
+
fields: Dict[str, FieldValue] = {}
|
|
553
|
+
offset = start
|
|
554
|
+
total = 0
|
|
555
|
+
device_id: Any = None
|
|
556
|
+
device_mismatch = False
|
|
557
|
+
compiled = self._compiled or []
|
|
558
|
+
|
|
559
|
+
for idx, cf in enumerate(compiled):
|
|
560
|
+
if cf.length is None:
|
|
561
|
+
if not allow_rest:
|
|
562
|
+
raise FrameInvalid(
|
|
563
|
+
"字段 {!r} 声明为“取到帧尾”,长度定界无法确定边界".format(cf.key)
|
|
564
|
+
)
|
|
565
|
+
# 留出后续“定长尾字段”(如 HDLC/PPP 的 FCS)的空间,否则 rest
|
|
566
|
+
# 会把尾部定长字段一并吞掉,导致它们永远解析不到。
|
|
567
|
+
# 遇到非定长后续字段即停止预留(无法安全预留时退回原语义)。
|
|
568
|
+
reserve = 0
|
|
569
|
+
for nxt in compiled[idx + 1:]:
|
|
570
|
+
if isinstance(nxt.length, int):
|
|
571
|
+
reserve += nxt.length
|
|
572
|
+
else:
|
|
573
|
+
break
|
|
574
|
+
length = len(buf) - offset - reserve
|
|
575
|
+
else:
|
|
576
|
+
length = self._resolve_length(cf, fields)
|
|
577
|
+
if length < 0:
|
|
578
|
+
raise FrameInvalid("字段 {!r} 计算出负长度 {}".format(cf.key, length))
|
|
579
|
+
total += length
|
|
580
|
+
if total > self.max_frame_size:
|
|
581
|
+
raise FrameInvalid("帧长超过 max_frame_size={}".format(self.max_frame_size))
|
|
582
|
+
|
|
583
|
+
end = offset + length
|
|
584
|
+
if end > len(buf):
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
if length == 0:
|
|
588
|
+
if cf.struct is not None:
|
|
589
|
+
raise FrameInvalid(
|
|
590
|
+
"字段 {!r} 长度为 0 但声明了 fmt {!r}".format(cf.key, cf.fmt)
|
|
591
|
+
)
|
|
592
|
+
raw = b""
|
|
593
|
+
val: Any = b""
|
|
594
|
+
else:
|
|
595
|
+
raw = bytes(buf[offset:end])
|
|
596
|
+
if cf.struct is not None:
|
|
597
|
+
# 长度表达式算出的字节数必须与 fmt 的字节数一致,否则视为脏数据
|
|
598
|
+
if cf.fmt_size is not None and cf.fmt_size != length:
|
|
599
|
+
raise FrameInvalid(
|
|
600
|
+
"字段 {!r} 长度 {} 与 fmt {!r} 不匹配".format(cf.key, length, cf.fmt)
|
|
601
|
+
)
|
|
602
|
+
val = cf.struct.unpack(raw)[0]
|
|
603
|
+
else:
|
|
604
|
+
val = raw
|
|
605
|
+
|
|
606
|
+
if cf.fixed is not None and not _fixed_matches(cf.fixed, val, raw):
|
|
607
|
+
raise FrameInvalid("字段 {!r} 常量不匹配: {!r}".format(cf.key, val))
|
|
608
|
+
|
|
609
|
+
fv = FieldValue(cf.key, raw, val)
|
|
610
|
+
fields[cf.key] = fv
|
|
611
|
+
offset = end
|
|
612
|
+
|
|
613
|
+
if cf.is_device:
|
|
614
|
+
device_id = raw if cf.struct is None else val
|
|
615
|
+
if self._device_id is not None and not _device_equal(self._device_id, fv):
|
|
616
|
+
device_mismatch = True
|
|
617
|
+
if self.enforce_device_id:
|
|
618
|
+
raise FrameInvalid(
|
|
619
|
+
"设备号不匹配:期望 {!r},收到 {!r}".format(self._device_id, val)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
if cf.is_crc and not self._verify_checksum(cf, fv, fields):
|
|
623
|
+
consumed = 0
|
|
624
|
+
if self.on_crc_error == "skip_frame" and compiled[-1] is cf:
|
|
625
|
+
consumed = total
|
|
626
|
+
raise FrameInvalid(
|
|
627
|
+
"字段 {!r} 校验失败".format(cf.key),
|
|
628
|
+
consumed=consumed,
|
|
629
|
+
crc_failed=True,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return _ParsedFields(fields, offset, device_id, device_mismatch)
|
|
633
|
+
|
|
634
|
+
# ------------------------------------------------------------------ 内部工具
|
|
635
|
+
def _resolve_length(self, cf: CompiledField, fields: Dict[str, FieldValue]) -> int:
|
|
636
|
+
length = cf.length
|
|
637
|
+
if isinstance(length, int):
|
|
638
|
+
return length
|
|
639
|
+
if isinstance(length, LengthExpr):
|
|
640
|
+
return length.evaluate(fields)
|
|
641
|
+
try:
|
|
642
|
+
value = length(fields)
|
|
643
|
+
except FrameCodecError:
|
|
644
|
+
raise
|
|
645
|
+
except Exception as exc: # 开发者的回调抛错 → 视为脏数据,不炸连接
|
|
646
|
+
raise FrameInvalid("字段 {!r} 长度回调异常: {}".format(cf.key, exc)) from exc
|
|
647
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
648
|
+
raise FrameInvalid("字段 {!r} 长度回调必须返回 int".format(cf.key))
|
|
649
|
+
return int(value)
|
|
650
|
+
|
|
651
|
+
def _verify_checksum(
|
|
652
|
+
self, cf: CompiledField, fv: FieldValue, fields: Dict[str, FieldValue]
|
|
653
|
+
) -> bool:
|
|
654
|
+
impl = self._crc_impls.get(cf.key, self.crc_imp)
|
|
655
|
+
payload = b"".join(fields[key].raw for key in (cf.group_refs or ()))
|
|
656
|
+
expected = impl(payload)
|
|
657
|
+
if isinstance(expected, (bytes, bytearray, memoryview)):
|
|
658
|
+
return bytes(expected) == fv.raw
|
|
659
|
+
if isinstance(expected, bool) or not isinstance(expected, int):
|
|
660
|
+
raise FrameRuleError(
|
|
661
|
+
"crc_imp 必须返回 int 或 bytes,实际返回 {}".format(type(expected).__name__)
|
|
662
|
+
)
|
|
663
|
+
if isinstance(fv.val, int):
|
|
664
|
+
return expected == fv.val
|
|
665
|
+
return expected == int.from_bytes(fv.raw, cf.byteorder, signed=False)
|
|
666
|
+
|
|
667
|
+
def _find_next_sync(self, start: int) -> int:
|
|
668
|
+
size = len(self._buf)
|
|
669
|
+
if start >= size:
|
|
670
|
+
return size
|
|
671
|
+
if self._mode == DELIMITER_FRAMING and self._delimiter_spec is not None:
|
|
672
|
+
index = find_end_delimiter(self._buf, start, self._delimiter_spec)
|
|
673
|
+
return size if index is None else index + 1
|
|
674
|
+
if not self._sync:
|
|
675
|
+
return start
|
|
676
|
+
index = self._buf.find(self._sync, start)
|
|
677
|
+
return size if index < 0 else index
|
|
678
|
+
|
|
679
|
+
def _preempt_offset(self, pos: int) -> Optional[int]:
|
|
680
|
+
"""在 ``pos`` 之后找一个"锚点处已经是完整帧"的位置,用于切断脏数据前缀。
|
|
681
|
+
|
|
682
|
+
只有当场就能完整解析(含校验)的帧才算数,避免把候选项误判成脏数据;
|
|
683
|
+
载荷里要同时出现锚点字节串和一个校验通过的帧才会误切,概率可以忽略。
|
|
684
|
+
"""
|
|
685
|
+
sync = self._sync
|
|
686
|
+
if not sync:
|
|
687
|
+
return None
|
|
688
|
+
start = pos + 1
|
|
689
|
+
while True:
|
|
690
|
+
index = self._buf.find(sync, start)
|
|
691
|
+
if index < 0:
|
|
692
|
+
return None
|
|
693
|
+
if self._frame_ready_at(index):
|
|
694
|
+
return index
|
|
695
|
+
start = index + 1
|
|
696
|
+
|
|
697
|
+
def _frame_ready_at(self, index: int) -> bool:
|
|
698
|
+
try:
|
|
699
|
+
return self._parse_fields(self._buf, index, allow_rest=False) is not None
|
|
700
|
+
except FrameInvalid:
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
# ------------------------------------------------------------------ 规则编译
|
|
704
|
+
def _ensure_compiled(self) -> None:
|
|
705
|
+
if self._compiled is not None:
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
if self.on_crc_error not in ("resync", "skip_frame"):
|
|
709
|
+
raise FrameRuleError(
|
|
710
|
+
"on_crc_error 只能是 'resync' 或 'skip_frame',实际 {!r}".format(self.on_crc_error)
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
spec = self.framing
|
|
714
|
+
if isinstance(spec, DelimiterFraming):
|
|
715
|
+
mode: Optional[str] = DELIMITER_FRAMING
|
|
716
|
+
self._delimiter_spec = spec
|
|
717
|
+
elif spec == LENGTH_FRAMING:
|
|
718
|
+
mode = LENGTH_FRAMING
|
|
719
|
+
elif spec == DELIMITER_FRAMING:
|
|
720
|
+
if self.delimiter is None:
|
|
721
|
+
raise FrameRuleError(
|
|
722
|
+
"framing='delimiter' 需要设置 delimiter=DelimiterFraming(...)"
|
|
723
|
+
)
|
|
724
|
+
mode, self._delimiter_spec = DELIMITER_FRAMING, self.delimiter
|
|
725
|
+
elif spec == AUTO_FRAMING:
|
|
726
|
+
if self.delimiter is None:
|
|
727
|
+
raise FrameRuleError(
|
|
728
|
+
"framing='auto' 需要同时设置 delimiter=DelimiterFraming(...)"
|
|
729
|
+
)
|
|
730
|
+
mode, self._delimiter_spec = None, self.delimiter
|
|
731
|
+
self._auto = True
|
|
732
|
+
else:
|
|
733
|
+
raise FrameRuleError(
|
|
734
|
+
"framing 必须是 {} 之一或 DelimiterFraming 实例,实际 {!r}".format(
|
|
735
|
+
sorted(FRAMING_MODES), spec
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
self._compiled = compile_rules(list(self.rules() or ()), self.device_field)
|
|
740
|
+
self._mode = mode
|
|
741
|
+
if mode == LENGTH_FRAMING:
|
|
742
|
+
for cf in self._compiled:
|
|
743
|
+
if cf.length is None:
|
|
744
|
+
raise FrameRuleError(
|
|
745
|
+
"字段 {!r} 声明为“取到帧尾”,只能用于分隔符定界"
|
|
746
|
+
"(改为 DelimiterFraming(...) 或 AUTO_FRAMING)".format(cf.key)
|
|
747
|
+
)
|
|
748
|
+
self._sync = self._resolve_sync(self._compiled[0])
|
|
749
|
+
elif mode is None:
|
|
750
|
+
# auto 模式需要长度定界的同步锚点做兜底重同步
|
|
751
|
+
self._sync = self._resolve_sync(self._compiled[0])
|
|
752
|
+
# 设备号若在编译前就已通过 bind_device/外部来源获得,直接拿来当锚点
|
|
753
|
+
self._refresh_sync()
|
|
754
|
+
|
|
755
|
+
def _resolve_sync(self, first: CompiledField) -> bytes:
|
|
756
|
+
sync = self.sync
|
|
757
|
+
if sync is not None:
|
|
758
|
+
if isinstance(sync, int):
|
|
759
|
+
if not 0 <= sync <= 0xFF:
|
|
760
|
+
raise FrameRuleError("sync 单字节值必须在 0..255")
|
|
761
|
+
return bytes([sync])
|
|
762
|
+
return bytes(sync)
|
|
763
|
+
if (
|
|
764
|
+
first.length == 1
|
|
765
|
+
and (first.fmt_size is None or first.fmt_size == 1)
|
|
766
|
+
and first.fixed is not None
|
|
767
|
+
):
|
|
768
|
+
fixed = first.fixed
|
|
769
|
+
if isinstance(fixed, int) and 0 <= fixed <= 0xFF:
|
|
770
|
+
return bytes([fixed])
|
|
771
|
+
if isinstance(fixed, (bytes, bytearray, memoryview)) and len(fixed) == 1:
|
|
772
|
+
return bytes(fixed)
|
|
773
|
+
return b""
|
|
774
|
+
|
|
775
|
+
def __repr__(self) -> str: # pragma: no cover - 调试展示
|
|
776
|
+
keys = [cf.key for cf in (self._compiled or ())]
|
|
777
|
+
return "<{} framing={} fields={} pending={} device={!r}>".format(
|
|
778
|
+
type(self).__name__, self._mode, keys, len(self._buf), self._device_id
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _fixed_matches(fixed: Any, val: Any, raw: bytes) -> bool:
|
|
783
|
+
if isinstance(fixed, (bytes, bytearray, memoryview)):
|
|
784
|
+
return bytes(fixed) == raw
|
|
785
|
+
return fixed == val
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _anchor_bytes(value: Any, cf: CompiledField) -> Optional[bytes]:
|
|
789
|
+
"""把已知设备号还原成帧内原始字节,供 ``_find_next_sync`` 使用。
|
|
790
|
+
|
|
791
|
+
定长字段才能还原(表达式长度取决于帧内容,静态推不出来)。
|
|
792
|
+
"""
|
|
793
|
+
if value is None:
|
|
794
|
+
return None
|
|
795
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
796
|
+
return bytes(value)
|
|
797
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
798
|
+
return None
|
|
799
|
+
if not isinstance(cf.length, int) or cf.length <= 0:
|
|
800
|
+
return None
|
|
801
|
+
try:
|
|
802
|
+
return value.to_bytes(cf.length, cf.byteorder, signed=False)
|
|
803
|
+
except OverflowError: # 设备号宽于字段,属配置矛盾,不设锚点即可
|
|
804
|
+
return None
|