fix(104主站工具): RX 解析按本仓 6 字节头取位——PN 在 cause 字节 bit6(道D 批2.5 R1)
大窗实锤(2026-10-25,V5 间隙连 2404 复放两笔越界写):板端对 51.0@0x8030 与 2.0@0x8034 均回 type=50 / cause 字节=0x47(7|PN 0x40)——固件行为正确、无需重烧。 本工具 _asdu 旧解析按标准 2 字节 COT 的习惯去 byte[3](本仓语义 = OA+EVI)找 PN (恒 False),且 cause 字节未 &0x3F(拒绝帧读成 cot=71)→ actcon_of(type, True) 永不匹配 → E2_dbig / E2_kband exec / G2 三条 PN 断言超时。历史用例全部 want_pn=False,False 配恒 False 恒真,故 B6~D2 无人暴露;E2/G2 首次真机断言 负确认即命中(审计定位 lane-d,task-21 修复)。 - 提取纯函数 decode_cot(cause_byte) -> (cot&0x3F, bit6),_asdu 改用;byte[3] 不再 参与 PN;TX 侧 asdu() 未动(与固件 RX 本就对称) - COT_PN 常量处 NOTE 注释:本仓 B4 自定义 6 字节压缩头(T/PN/RBT=bit7/6/4 同字节, byte[3]=OA+EVI),引用 App/protocol/iec104/iec104_asdu.c put_hdr 为准;标注根治 走 COT=2 标准化合并单(登记簿「104 ASDU 头非标-COT1 压缩」,本票不动固件) - 新增离线样本单测 test/host/test_iec104_master_rx.py(纯 stdlib 无台架,6 用例: 两条窗口捕获帧字节重建 + 正例 actcon + 旧错误解码反证 + matcher 语义) - RX log 语义变化:拒绝帧从 cot=71 变为 cot=7 + " PN" 后缀(v5_log_gap_report.py 已核无 cot= 解析耦合) gate:py_compile 双文件过;单测 6/6 OK;全钩子常规跑(见提交记录);写域仅 test/host/iec104_master.py + test/host/(票面内),未加钩子(yaml 在写域外, 如需常态化由 lead 批 2.5 顺手)。
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Offline sample-frame selftest for iec104_master.py RX decode (task-21/m104fix).
|
||||
|
||||
Why offline (no bench): the 2026-10-25 big-window leg proved on the wire that the
|
||||
slave answers an out-of-range C_SE_NC_1 with type=50 and cause byte 0x47 (cot 7 |
|
||||
PN 0x40) -- two captured events: 51.0 @ 0x8030 and 2.0 @ 0x8034. The old RX path
|
||||
looked for PN in byte[3] (OA byte, always 0) and did not mask the cause byte, so
|
||||
such frames decoded as cot=71/pn=False and actcon_of(type, True) could never match
|
||||
(negative-confirm PN was never asserted on hardware before that window).
|
||||
|
||||
These tests pin the FIXED decoder against reconstructed sample frames (identical to
|
||||
the captured wire bytes: type, vsq=1, cause, oa, ca(LE16), echoed obj ioa3+float4+
|
||||
qdc1). Pure stdlib, no socket, no hardware.
|
||||
|
||||
Known in-house-format limitation (NOT reachable on the wire today, kept visible):
|
||||
the RBT bit4 lives inside the 6-bit cause field of the in-house 6-byte head, so a
|
||||
cause value 16..23 would collide with RBT=1. The firmware never sets RBT on TX
|
||||
(iec104_link.c passes cot_hi=0/0x40 only); proper fix belongs to the standard
|
||||
COT=2 rework (registry: 104 ASDU head in-house-COT1 compression), not this task.
|
||||
|
||||
Usage: python test/host/test_iec104_master_rx.py [-v]
|
||||
Exit code: 0 = all PASS.
|
||||
Created 2026-10-25 (lane-d, task-21).
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import iec104_master as m104 # noqa: E402
|
||||
|
||||
CA = m104.CA
|
||||
|
||||
|
||||
def actcon_frame(cause_byte, ioa, val):
|
||||
"""Rebuild a slave C_SE actcon exactly as iec104_link.c s_cmd_con echoes it."""
|
||||
obj = m104.put_ioa(ioa) + struct.pack("<f", val) + bytes([0x00])
|
||||
return bytes([m104.T_C_SE_NC_1, 0x01, cause_byte, 0x00,
|
||||
CA & 0xFF, (CA >> 8) & 0xFF]) + obj
|
||||
|
||||
|
||||
class DecodeCot(unittest.TestCase):
|
||||
"""Pure-cause-byte semantics: wire bytes captured in the 2026-10-25 window."""
|
||||
|
||||
def test_reject_cause_0x47(self):
|
||||
# captured x2: "RX I type=50 cot=71" == 7|0x40 pre-fix display
|
||||
self.assertEqual(m104.decode_cot(0x47), (7, True))
|
||||
|
||||
def test_accept_cause_0x07(self):
|
||||
self.assertEqual(m104.decode_cot(0x07), (7, False))
|
||||
|
||||
def test_reject_frames_carry_expected_values(self):
|
||||
f1 = actcon_frame(0x47, 0x8030, 51.0)
|
||||
f2 = actcon_frame(0x47, 0x8034, 2.0)
|
||||
self.assertEqual(f1[2], 71)
|
||||
self.assertEqual(f2[2], 71)
|
||||
# cause value survives masking, flags are gone from cot
|
||||
self.assertEqual(m104.decode_cot(f1[2]), (7, True))
|
||||
|
||||
|
||||
class EndToEndMatch(unittest.TestCase):
|
||||
"""decoder output -> events tuple -> actcon_of matcher (what window waits on)."""
|
||||
|
||||
def test_reject_matches_negative_actcon(self):
|
||||
f = actcon_frame(0x47, 0x8030, 51.0)
|
||||
cot, pn = m104.decode_cot(f[2])
|
||||
ev = ("asdu", (f[0], cot, pn, f[6:]))
|
||||
self.assertTrue(m104.actcon_of(m104.T_C_SE_NC_1, True)(ev))
|
||||
# old wrong decode would build (50, 71, False) and miss:
|
||||
legacy = ("asdu", (f[0], f[2], (f[3] & m104.COT_PN) != 0, f[6:]))
|
||||
self.assertFalse(m104.actcon_of(m104.T_C_SE_NC_1, True)(legacy))
|
||||
|
||||
def test_positive_still_matches_positive(self):
|
||||
f = actcon_frame(0x07, 0x8030, 0.7)
|
||||
cot, pn = m104.decode_cot(f[2])
|
||||
ev = ("asdu", (f[0], cot, pn, f[6:]))
|
||||
self.assertTrue(m104.actcon_of(m104.T_C_SE_NC_1, False)(ev))
|
||||
self.assertFalse(m104.actcon_of(m104.T_C_SE_NC_1, True)(ev))
|
||||
|
||||
def test_matcher_none_wants_any_pn(self):
|
||||
for cause in (0x07, 0x47):
|
||||
cot, pn = m104.decode_cot(cause)
|
||||
ev = ("asdu", (50, cot, pn, b""))
|
||||
self.assertTrue(m104.actcon_of(m104.T_C_SE_NC_1)(ev))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user