#!/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)