From 2808ea9fb8cc5db66c824aa2b46dd464b3e5d1e7 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 25 Oct 2026 16:26:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(104=E4=B8=BB=E7=AB=99=E5=B7=A5=E5=85=B7):?= =?UTF-8?q?=20RX=20=E8=A7=A3=E6=9E=90=E6=8C=89=E6=9C=AC=E4=BB=93=206=20?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=A4=B4=E5=8F=96=E4=BD=8D=E2=80=94=E2=80=94?= =?UTF-8?q?PN=20=E5=9C=A8=20cause=20=E5=AD=97=E8=8A=82=20bit6=EF=BC=88?= =?UTF-8?q?=E9=81=93D=20=E6=89=B92.5=20R1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大窗实锤(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 顺手)。 --- test/host/iec104_master.py | 28 ++++++++- test/host/test_iec104_master_rx.py | 91 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 test/host/test_iec104_master_rx.py diff --git a/test/host/iec104_master.py b/test/host/iec104_master.py index 81415364..cd8020ed 100644 --- a/test/host/iec104_master.py +++ b/test/host/iec104_master.py @@ -26,6 +26,9 @@ Exit codes: 0 = all checks PASS; 1 = any check FAIL; 2 = connect or protocol error. Output is pure ASCII (redirect stdout to archive the round-trip log as bench evidence). +RX decode fix (2026-10-25, task-21/m104fix): cause-byte semantics above; +offline sample-frame selftest: python test/host/test_iec104_master_rx.py + Usage: python iec104_master.py [--ip 192.168.1.100] [--port 2404] [--addr 0x8858] [--low 0.05] [--restore 10.0] @@ -61,7 +64,15 @@ T_C_IC_NA_1, T_C_RD_NA_1 = 100, 102 COT_BG, COT_SPONT, COT_INIT, COT_REQ = 2, 3, 4, 5 COT_ACT, COT_ACTCON, COT_ACTTERM = 6, 7, 8 -COT_PN = 0x40 # COT high byte bit6: negative confirm +COT_PN = 0x40 # cause byte bit6: negative confirm +# NOTE(2026-10-25 m104fix): THIS SLAVE USES THE B4 IN-HOUSE 6-BYTE ASDU HEAD -- +# T/PN/RBT FLAGS LIVE IN THE CAUSE BYTE (bit7/bit6/bit4), byte[3] IS OA+EVI(bit7). +# SEE App/protocol/iec104/iec104_asdu.c iec104_asdu_put_hdr(). The old RX path read +# PN from byte[3] (standard 2-byte-COT habit) -> pn was ALWAYS False and a rejected +# command logged cot=71 (7|0x40), so actcon_of(type, True) could never match. +# Bench proof 2026-10-25 (V5-gap replay): two out-of-range C_SE writes +# (51.0 @ 0x8030, 2.0 @ 0x8034) each answered type=50 cause=0x47 on the wire. +# Full standardization (real COT=2 head) is tracked separately, NOT fixed here. RQT_GROUP1 = 20 CA = 1 # common address fixed to 1 in V0.1 SCAN_COTS = (COT_BG, COT_INIT) @@ -132,6 +143,17 @@ def cp56_str(b): return ("IV " if iv else "") + s +def decode_cot(cause_byte): + """Split the in-house cause byte into (cot, pn). + + Pure function so it can be pinned offline without a bench -- sample frames in + test/host/test_iec104_master_rx.py. Mirrors iec104_asdu.c put_hdr()/parse: + cot = cause & 0x3F, PN = cause bit6 (RBT bit4 / T bit7 stay inside the same + byte and are transparently masked out of cot). byte[3] (OA+EVI) is NOT COT hi. + """ + return (cause_byte & 0x3F, (cause_byte & COT_PN) != 0) + + class Master(object): """Minimal 104 master. k/w/t are covered by "S after every recv batch" (far faster than t1=15s / t2=10s), so no timers are needed here.""" @@ -225,8 +247,8 @@ class Master(object): def _asdu(self, a, nr): if len(a) < 6: raise RuntimeError("short ASDU") - type_id, vsq, cot, cot_hi = a[0], a[1], a[2], a[3] - pn = (cot_hi & COT_PN) != 0 + type_id, vsq = a[0], a[1] + cot, pn = decode_cot(a[2]) # flags live in the cause byte (see COT_PN note) sq = (vsq & 0x80) != 0 n = vsq & 0x7F obj = a[6:] diff --git a/test/host/test_iec104_master_rx.py b/test/host/test_iec104_master_rx.py new file mode 100644 index 00000000..0fb7c7ef --- /dev/null +++ b/test/host/test_iec104_master_rx.py @@ -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("> 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)