7efa58d9a1
分两段(用户裁定 2026-11-02):先做地址迁移,再把同类字面量收敛成
单一真源,以后再改地址只动一处。
一、IP 迁移 192.168.1.100 → 192.168.31.100(gw 同步 → .31.1)
- 工厂默认:Drivers/BSP/NETCFG/net_cfg.c 的 NET_CFG_DEF_IP/_GW
- 注释/文档示例、主机与板级测试、台架 py 脚本、两份 sim_config.ini
- 字节序教学点同步换算 hex:0x6401A8C0 → 0x641FA8C0(内存
C0 A8 1F 64),旧版示例引文与历史 Trap 记录按冻结纪律不动
- UI 黄金基准:网口设置 4 态 + shot_info 共 5 张 .bin 由
check_content_pages.py --update 重刷(渲染值 IP 变了,属预期变更)
- 保留判定:客户点表引用(IEC104/继电保护规划)、芯片手册摘录、
日期冻结的变更记录行、已退役台架档案 MDK-ARM/retracted_bench_*
二、单一真源收口(A+B+C+D,用户批准全做)
- 新建 test/host/bench_defaults.py:台架 py 默认值唯一定义点
(DEVICE_IP/DEVICE_GW/PC_IP/NET_BCAST),22 个脚本改为引用;
MDK-ARM 两探针与 scripts/board_ui_nav.py 经 sys.path shim 引入
- test/board/net_test_task.h 新增 TEST_PC_PEER_IP,板测三处
PHASE*_REMOTE_IP 改引该宏(保留 -D 覆盖);Phase1 断言改
memcmp 数组比较,数组各只出现一次
- simulator/tests/check_content_pages.py 从 net_cfg.c 正则解析
NET_CFG_DEF_* 推导期望值(解析失败即报错、不静默兜底),
脚本内台架 IP 字面量清零
- 装饰性注释去具体值(net_cfg.h/net_socket.h/net_socket.c/
net_socket_sim.c);字节序教学 hex 对刻意保留为唯一写值点
- 刻意不动:主机单测夹具 IP(逐文件钉住的任意值是正确测试
实践)、文档正文 IP(那是文档内容本身)
顺带:scripts/gen_task_table.py 规范串清掉「,B5」——编号清退提交
72b4b094 改了受管区没重跑生成器,task-table 门禁因此一直红
验证:产品 @build 0/0;CH395F 套件开关构建 0/0 后还原产品态;
6 套主机单测 rc=0;content_pages(含宏推导路径)/菜单 8 态/
推流位比对全 PASS;bom-drift 99 无漂移;doc-links/coding-standard/
cppcheck 绿;py_compile 23 文件 + --help 冒烟(含 shim 绝对路径)
641 lines
26 KiB
Python
641 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""B6 bench leg: IEC 60870-5-104 master client (GI + COV round-trip).
|
|
|
|
Historical bench leg; current acceptance index: docs/任务规划/登记簿.md §7.
|
|
docs/子系统设计/IEC104上行设计说明.md §3/§4. Constants mirror
|
|
App/protocol/iec104/iec104_asdu.h / iec104_link.h.
|
|
|
|
Flow:
|
|
1. TCP connect device :2404 (factory default; --port overrides),
|
|
STARTDT act/con (enter RUN).
|
|
2. General interrogation C_IC_NA_1 (QOI=20): expect actcon, background
|
|
scan (YX double-point group SQ=1 -> YX single-point group SQ=1 ->
|
|
YC group SQ=1 -> frequency single point), actterm.
|
|
3. Deterministic COV trigger: bench analog inputs are floating (phasor
|
|
rms is 0..0.3 raw-code noise only; the standard source belongs to
|
|
B10), so we use the two-step C_SE_NC_1 write to temporarily lower
|
|
the OC1 current setting (comm addr 0x8858, default 10.0 A) to
|
|
0.05 A -> protectTask (20 ms cadence) asserts OC1 -> YX16/YX15
|
|
spontaneous (COT=3) uplink. This is exactly the "104 YX linkage"
|
|
leg left open by B5. Then restore 10.0 A -> YX16 returns.
|
|
4. YK11 (0x600A) two-step select/execute -> positive actcon (reset
|
|
request consumed asynchronously by protectTask, B5 contract).
|
|
5. C_RD read back 0x8858 ~= 10.0 (restore confirmation).
|
|
|
|
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 <设备IP>] [--port 2404]
|
|
[--addr 0x8858] [--low 0.05]
|
|
[--restore 10.0]
|
|
|
|
B7 bench leg modes (setpoint persistence + global soft plate; historical
|
|
bench leg, firmware side App/cfg/prot_set_store.c):
|
|
--b7-pre : before reset: mark setpoint 7.5 (C_SE+C_RD), YK12 OFF (two-step)
|
|
-> low setpoint 0.05 must NOT trip YX16 (plate OUT disables
|
|
protection); YK12 ON -> YX16 trips (linkage); restore 10.0;
|
|
leave plate OUT (persistence proof), wait for dirty->save.
|
|
--b7-post: after reset: C_RD 10.0 (setpoint persisted); low setpoint 0.05
|
|
still must NOT trip (plate OUT persisted); YK12 ON -> YX16
|
|
trips (linkage after reboot); restore 10.0, leave plate IN.
|
|
--b7-mark / --b7-check [--mark 7.5]: write a distinctive NON-default
|
|
setpoint (save), reset, read it back (literal "setpoint
|
|
hold" proof, distinguishable from factory default), restore.
|
|
"""
|
|
import argparse
|
|
import collections
|
|
|
|
import bench_defaults # 台架连接默认值单一真源
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
# ---- constants (mirror iec104_asdu.h / iec104_link.h) -----------------------
|
|
U_STARTDT_ACT, U_STARTDT_CON = 0x07, 0x0B
|
|
U_TESTFR_ACT, U_TESTFR_CON = 0x43, 0x83
|
|
|
|
T_M_SP_NA_1, T_M_DP_NA_1, T_M_ME_NC_1 = 1, 3, 13
|
|
T_M_SP_TB_1 = 30 # single point + CP56Time2a (B9 SOE spontaneous)
|
|
T_C_SE_NC_1, T_C_DC_NA_1 = 50, 52
|
|
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 # 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)
|
|
|
|
YX_NAMES = {15: "YX15 fault-total", 16: "YX16 OC-I", 17: "YX17 OC-II",
|
|
18: "YX18 OC-III", 23: "YX23 overload", 31: "YX31 prot-block"}
|
|
|
|
t_start = time.time()
|
|
|
|
|
|
def log(msg):
|
|
print("[%9.3fs] %s" % (time.time() - t_start, msg), flush=True)
|
|
|
|
|
|
def hexs(b):
|
|
return " ".join("%02X" % x for x in b)
|
|
|
|
|
|
# ---- APCI / ASDU builders ----------------------------------------------------
|
|
def u_frame(code):
|
|
return bytes([0x68, 0x04, code, 0x00, 0x00, 0x00])
|
|
|
|
|
|
def s_frame(vr):
|
|
return bytes([0x68, 0x04, 0x01, 0x00, (vr << 1) & 0xFE, (vr >> 7) & 0x7F])
|
|
|
|
|
|
def i_frame(vs, vr, body):
|
|
ctrl = bytes([(vs << 1) & 0xFE, (vs >> 7) & 0x7F,
|
|
(vr << 1) & 0xFE, (vr >> 7) & 0x7F])
|
|
return bytes([0x68, len(body) + 4]) + ctrl + body
|
|
|
|
|
|
def put_ioa(ioa):
|
|
return bytes([ioa & 0xFF, (ioa >> 8) & 0xFF, (ioa >> 16) & 0xFF])
|
|
|
|
|
|
def get_ioa(b, off=0):
|
|
return b[off] | (b[off + 1] << 8) | (b[off + 2] << 16)
|
|
|
|
|
|
def asdu(type_id, vsq, cot, cot_hi, ioa=None, payload=b""):
|
|
h = bytes([type_id, vsq, cot & 0x3F, cot_hi, CA & 0xFF, (CA >> 8) & 0xFF])
|
|
if ioa is not None:
|
|
h += put_ioa(ioa)
|
|
return h + payload
|
|
|
|
|
|
def cp56_str(b):
|
|
"""Decode CP56Time2a (7B) -> 'YYYY-MM-DD HH:MM:SS.mmm' (UTC) or 'IV-...'.
|
|
B9: M_SP_TB_1 spontaneous time tag (sec-resolution, ms field = sec*1000)."""
|
|
iv = (b[2] & 0x80) != 0
|
|
ms_min = b[0] | (b[1] << 8)
|
|
minute = b[2] & 0x3F
|
|
hour = b[3] & 0x1F
|
|
day = b[4] & 0x1F
|
|
mon = b[5] & 0x0F
|
|
year = 2000 + (b[6] & 0x7F)
|
|
sec = ms_min // 1000
|
|
ms = ms_min % 1000
|
|
try:
|
|
import datetime
|
|
d = datetime.datetime(year, mon, day, hour, minute, sec, ms * 1000)
|
|
s = d.strftime("%Y-%m-%d %H:%M:%S") + (".%03d" % ms)
|
|
except ValueError:
|
|
s = "%04d-%02d-%02d %02d:%02d:%02d.%03d" % (
|
|
year, mon, day, hour, minute, sec, ms)
|
|
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."""
|
|
|
|
def __init__(self, ip, port):
|
|
self.sock = socket.create_connection((ip, port), timeout=10)
|
|
self.sock.settimeout(1.0)
|
|
self.rx = b""
|
|
self.vs = 0
|
|
self.vr = 0
|
|
self.vr_dirty = False
|
|
self.events = collections.deque() # (kind, detail) tuples
|
|
self.yx = {} # ioa -> (val, iv)
|
|
self.yc = {} # ioa -> (val, qds)
|
|
|
|
# ---- send ----
|
|
def _send(self, data, tag):
|
|
log("TX %s %s" % (tag, hexs(data)))
|
|
self.sock.sendall(data)
|
|
|
|
def send_i(self, body, tag):
|
|
self._send(i_frame(self.vs, self.vr, body), tag)
|
|
self.vs = (self.vs + 1) & 0x7FFF
|
|
|
|
def _send_s(self):
|
|
if self.vr_dirty:
|
|
self._send(s_frame(self.vr), "S N(R)=%d" % self.vr)
|
|
self.vr_dirty = False
|
|
|
|
# ---- receive pump ----
|
|
def pump_until(self, deadline):
|
|
"""Pump RX until deadline (non-blocking semantics for wait_event)."""
|
|
while time.time() < deadline:
|
|
try:
|
|
chunk = self.sock.recv(512)
|
|
except socket.timeout:
|
|
self._send_s()
|
|
continue
|
|
if not chunk:
|
|
raise RuntimeError("peer closed (EOF)")
|
|
self.rx += chunk
|
|
while len(self.rx) >= 2 and self.rx[0] == 0x68:
|
|
ln = self.rx[1]
|
|
if ln < 4 or ln > 253:
|
|
raise RuntimeError("bad APDU length %d" % ln)
|
|
if len(self.rx) < 2 + ln:
|
|
break
|
|
apdu, self.rx = self.rx[:2 + ln], self.rx[2 + ln:]
|
|
self._handle(apdu)
|
|
if self.rx and self.rx[0] != 0x68:
|
|
raise RuntimeError("stream desync at 0x%02X" % self.rx[0])
|
|
self._send_s()
|
|
|
|
def wait_event(self, pred, timeout):
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
for i, e in enumerate(self.events):
|
|
if pred(e):
|
|
return self.events[i]
|
|
if time.time() >= deadline:
|
|
return None
|
|
self.pump_until(min(deadline, time.time() + 1.0))
|
|
|
|
# ---- frame handling ----
|
|
def _handle(self, apdu):
|
|
c = apdu[2:6]
|
|
b0, b1, b2, b3 = c[0], c[1], c[2], c[3]
|
|
if (b0 & 0x01) == 0: # I format
|
|
ns = (b0 >> 1) | ((b1 & 0x7F) << 7)
|
|
nr = (b2 >> 1) | ((b3 & 0x7F) << 7)
|
|
if ns != self.vr:
|
|
raise RuntimeError("N(S)=%d out of order (expect %d)"
|
|
% (ns, self.vr))
|
|
self.vr = (self.vr + 1) & 0x7FFF
|
|
self.vr_dirty = True
|
|
self._asdu(apdu[6:], nr)
|
|
elif (b0 & 0x03) == 0x01: # S format
|
|
nr = (b2 >> 1) | ((b3 & 0x7F) << 7)
|
|
log("RX S N(R)=%d" % nr)
|
|
else: # U format
|
|
if b0 == U_STARTDT_CON:
|
|
log("RX U STARTDT con")
|
|
self.events.append(("startdt_con", None))
|
|
elif b0 == U_TESTFR_ACT:
|
|
self._send(u_frame(U_TESTFR_CON), "U TESTFR con")
|
|
elif b0 == U_TESTFR_CON:
|
|
log("RX U TESTFR con")
|
|
else:
|
|
log("RX U code=0x%02X" % b0)
|
|
|
|
def _asdu(self, a, nr):
|
|
if len(a) < 6:
|
|
raise RuntimeError("short ASDU")
|
|
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:]
|
|
|
|
if type_id in (T_M_SP_NA_1, T_M_DP_NA_1, T_M_SP_TB_1):
|
|
# B9: M_SP_TB_1 (30) = SIQ + CP56Time2a (8B/point); NA forms = 1B.
|
|
esz = 8 if type_id == T_M_SP_TB_1 else 1
|
|
pts = [] # (ioa, raw, elem_off_in_obj)
|
|
if sq:
|
|
base = get_ioa(obj)
|
|
for i in range(n):
|
|
pts.append((base + i, obj[3 + i * esz], 3 + i * esz))
|
|
else:
|
|
off = 0
|
|
for _ in range(n):
|
|
pts.append((get_ioa(obj, off), obj[off + 3], off + 3))
|
|
off += 3 + esz
|
|
for ioa, raw, eoff in pts:
|
|
val = (raw & 0x03) if type_id == T_M_DP_NA_1 else (raw & 0x01)
|
|
iv = (raw & 0x80) != 0
|
|
self.yx[ioa] = (val, iv)
|
|
ts = ""
|
|
if type_id == T_M_SP_TB_1:
|
|
ts = " ts=%s" % cp56_str(obj[eoff + 1:eoff + 8])
|
|
self.events.append(("yx", (ioa, val, iv, cot)))
|
|
if ioa in YX_NAMES or cot == COT_SPONT:
|
|
log("RX I %s ioa=%d val=%d iv=%d cot=%d%s [N(R)=%d]"
|
|
% ({T_M_DP_NA_1: "M_DP", T_M_SP_NA_1: "M_SP",
|
|
T_M_SP_TB_1: "M_SP_TB"}[type_id],
|
|
ioa, val, 1 if iv else 0, cot, ts, nr))
|
|
elif type_id == T_M_ME_NC_1:
|
|
pts = []
|
|
if sq:
|
|
base = get_ioa(obj)
|
|
for i in range(n):
|
|
v = struct.unpack_from("<f", obj, 3 + i * 5)[0]
|
|
pts.append((base + i, v, obj[3 + i * 5 + 4]))
|
|
else:
|
|
off = 0
|
|
for _ in range(n):
|
|
v = struct.unpack_from("<f", obj, off + 3)[0]
|
|
pts.append((get_ioa(obj, off), v, obj[off + 3 + 4]))
|
|
off += 8
|
|
for ioa, v, qds in pts:
|
|
self.yc[ioa] = (v, qds)
|
|
self.events.append(("yc", (ioa, v, qds, cot)))
|
|
if cot != COT_BG and cot != COT_INIT:
|
|
log("RX I M_ME_NC ioa=0x%04X val=%.3f qds=0x%02X cot=%d"
|
|
% (ioa, v, qds, cot))
|
|
else:
|
|
self.events.append(("asdu", (type_id, cot, pn, bytes(obj))))
|
|
log("RX I type=%d cot=%d%s body=%s"
|
|
% (type_id, cot, " PN" if pn else "", hexs(obj)))
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.settimeout(2.0)
|
|
self.sock.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---- test flow ----------------------------------------------------------------
|
|
class Checks(object):
|
|
def __init__(self):
|
|
self.items = []
|
|
|
|
def add(self, name, ok, detail=""):
|
|
self.items.append((name, bool(ok), detail))
|
|
log("CHECK %-28s %s %s" % (name, "PASS" if ok else "FAIL", detail))
|
|
|
|
|
|
def actcon_of(type_id, want_pn=None):
|
|
return lambda e: (e[0] == "asdu" and e[1][0] == type_id
|
|
and e[1][1] == COT_ACTCON
|
|
and (want_pn is None or e[1][2] == want_pn))
|
|
|
|
|
|
def spont(ioa, val):
|
|
return lambda e: (e[0] == "yx" and e[1][0] == ioa
|
|
and e[1][1] == val and e[1][3] == COT_SPONT)
|
|
|
|
|
|
def gi_baseline(m, ck, tag):
|
|
"""General interrogation; wait for actterm (baseline YX state loaded)."""
|
|
m.send_i(asdu(T_C_IC_NA_1, 1, COT_ACT, 0, ioa=0, payload=bytes([RQT_GROUP1])),
|
|
"I C_IC act (%s)" % tag)
|
|
ck.add("gi_actterm_%s" % tag, m.wait_event(
|
|
lambda e: (e[0] == "asdu" and e[1][0] == T_C_IC_NA_1
|
|
and e[1][1] == COT_ACTTERM), 15.0) is not None)
|
|
|
|
|
|
def se_write(m, ck, addr, val, tag):
|
|
"""Two-step C_SE_NC_1 write (select then execute), both must actcon."""
|
|
pl = struct.pack("<f", val)
|
|
m.send_i(asdu(T_C_SE_NC_1, 1, COT_ACT, 0, ioa=addr,
|
|
payload=pl + bytes([0x80])),
|
|
"I C_SE select 0x%X = %.3f (%s)" % (addr, val, tag))
|
|
ck.add("se_sel_%s" % tag, m.wait_event(actcon_of(T_C_SE_NC_1, False),
|
|
5.0) is not None)
|
|
m.send_i(asdu(T_C_SE_NC_1, 1, COT_ACT, 0, ioa=addr,
|
|
payload=pl + bytes([0x00])),
|
|
"I C_SE exec 0x%X = %.3f (%s)" % (addr, val, tag))
|
|
ck.add("se_exe_%s" % tag, m.wait_event(actcon_of(T_C_SE_NC_1, False),
|
|
5.0) is not None)
|
|
|
|
|
|
def rd_set(m, ck, addr, want, name):
|
|
m.send_i(asdu(T_C_RD_NA_1, 1, COT_REQ, 0, ioa=addr), "I C_RD 0x%X" % addr)
|
|
e = m.wait_event(lambda ev: (ev[0] == "yc" and ev[1][0] == addr
|
|
and ev[1][3] == COT_REQ), 5.0)
|
|
ck.add(name, e is not None and abs(e[1][1] - want) < 0.1,
|
|
"read=%.3f want=%.3f" % (e[1][1], want) if e else "no response")
|
|
|
|
|
|
def yk_two_step(m, ck, ioa, qdc_sel, qdc_exe, name):
|
|
m.send_i(asdu(T_C_DC_NA_1, 1, COT_ACT, 0, ioa=ioa,
|
|
payload=bytes([qdc_sel])),
|
|
"I C_DC 0x%X select QDC=%02X" % (ioa, qdc_sel))
|
|
ck.add(name + "_sel", m.wait_event(actcon_of(T_C_DC_NA_1, False),
|
|
5.0) is not None)
|
|
m.send_i(asdu(T_C_DC_NA_1, 1, COT_ACT, 0, ioa=ioa,
|
|
payload=bytes([qdc_exe])),
|
|
"I C_DC 0x%X execute QDC=%02X" % (ioa, qdc_exe))
|
|
ck.add(name + "_exe", m.wait_event(actcon_of(T_C_DC_NA_1, False),
|
|
5.0) is not None)
|
|
|
|
|
|
def yx16_off_confirmed(m, ck, name):
|
|
"""YX16 back to OFF: spontaneous COT=3, else second GI background scan."""
|
|
m.events.clear()
|
|
if m.wait_event(spont(16, 0), 5.0) is not None:
|
|
ck.add(name, True, "via spontaneous COT=3")
|
|
return
|
|
log("no spont OFF within 5s -> second GI background scan")
|
|
gi_baseline(m, ck, "off-scan")
|
|
ck.add(name, m.yx.get(16) == (0, False), "via GI scan, yx16=%r"
|
|
% (m.yx.get(16),))
|
|
|
|
|
|
def b7_pre(m, ck, args):
|
|
"""B7 phase 1 (before reset): plate linkage + mark setpoint for save."""
|
|
addr = int(args.addr, 0)
|
|
gi_baseline(m, ck, "pre")
|
|
v16 = m.yx.get(16)
|
|
ck.add("baseline_yx16_off", v16 is not None and v16 == (0, False),
|
|
"yx16=%r" % (v16,))
|
|
|
|
se_write(m, ck, addr, 7.5, "mark")
|
|
rd_set(m, ck, addr, 7.5, "rd_mark_7.5")
|
|
|
|
yk_two_step(m, ck, 0x600B, 0x81, 0x01, "yk12_off") # plate OUT
|
|
se_write(m, ck, addr, args.low, "low") # 0.05 A
|
|
m.events.clear()
|
|
ck.add("plate_out_no_trip", m.wait_event(spont(16, 1), 3.0) is None,
|
|
"YX16 must stay OFF while plate OUT (protection disabled)")
|
|
rd_set(m, ck, addr, args.low, "rd_low_0.05") # setpoint still writable
|
|
|
|
yk_two_step(m, ck, 0x600B, 0x82, 0x02, "yk12_on") # plate IN -> linkage
|
|
m.events.clear()
|
|
ck.add("spont_yx16_ON", m.wait_event(spont(16, 1), 5.0) is not None)
|
|
ck.add("spont_yx15_ON", m.wait_event(spont(15, 1), 3.0) is not None)
|
|
|
|
se_write(m, ck, addr, args.restore, "restore") # back to 10.0
|
|
yx16_off_confirmed(m, ck, "yx16_off_after_restore")
|
|
|
|
yk_two_step(m, ck, 0x600B, 0x81, 0x01, "yk12_off_final") # leave OUT
|
|
log("wait 2s: dirty -> SYS_EVT_PROT_SET_SAVE -> /prot_set.bin")
|
|
time.sleep(2.0)
|
|
|
|
|
|
def b7_post(m, ck, args):
|
|
"""B7 phase 2 (after reset): persistence + linkage after reboot."""
|
|
addr = int(args.addr, 0)
|
|
gi_baseline(m, ck, "post")
|
|
v16 = m.yx.get(16)
|
|
ck.add("baseline_yx16_off", v16 is not None and v16 == (0, False),
|
|
"yx16=%r (plate OUT after reboot -> protection disabled)" % (v16,))
|
|
|
|
rd_set(m, ck, addr, args.restore, "persist_rd_10.0") # setpoint persisted
|
|
|
|
se_write(m, ck, addr, args.low, "low")
|
|
m.events.clear()
|
|
ck.add("plate_out_persisted_no_trip",
|
|
m.wait_event(spont(16, 1), 3.0) is None,
|
|
"plate OUT state survived the reset")
|
|
|
|
yk_two_step(m, ck, 0x600B, 0x82, 0x02, "yk12_on") # linkage after reboot
|
|
m.events.clear()
|
|
ck.add("spont_yx16_ON_after_reboot",
|
|
m.wait_event(spont(16, 1), 5.0) is not None)
|
|
|
|
se_write(m, ck, addr, args.restore, "restore")
|
|
yx16_off_confirmed(m, ck, "yx16_off_final")
|
|
log("wait 1s: final dirty -> save, leave plate IN")
|
|
time.sleep(1.0)
|
|
|
|
|
|
def b7_mark(m, ck, args):
|
|
"""B7 distinctive-value mark: write non-default setpoint, let it save."""
|
|
addr = int(args.addr, 0)
|
|
se_write(m, ck, addr, args.mark, "distinct")
|
|
rd_set(m, ck, addr, args.mark, "rd_distinct")
|
|
log("wait 1.5s: dirty -> save (/prot_set.bin)")
|
|
time.sleep(1.5)
|
|
|
|
|
|
def b7_check(m, ck, args):
|
|
"""B7 distinctive-value check after reset: must read back non-default,
|
|
then restore default so the bench is left in a clean state."""
|
|
addr = int(args.addr, 0)
|
|
rd_set(m, ck, addr, args.mark, "persist_distinct_after_reset")
|
|
se_write(m, ck, addr, args.restore, "restore")
|
|
log("wait 1s: restore saved, bench left at defaults")
|
|
time.sleep(1.0)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--ip", default=bench_defaults.DEVICE_IP)
|
|
ap.add_argument("--port", type=int, default=2404)
|
|
ap.add_argument("--addr", default="0x8858",
|
|
help="setpoint comm addr to drive COV (OC1 current)")
|
|
ap.add_argument("--low", type=float, default=0.05,
|
|
help="temporary low OC1 setting (A) to force pickup")
|
|
ap.add_argument("--restore", type=float, default=10.0,
|
|
help="OC1 default setting to restore (A)")
|
|
ap.add_argument("--b7-pre", action="store_true",
|
|
help="B7 phase 1 (before reset) instead of B6 flow")
|
|
ap.add_argument("--b7-post", action="store_true",
|
|
help="B7 phase 2 (after reset) instead of B6 flow")
|
|
ap.add_argument("--b7-mark", action="store_true",
|
|
help="B7 write distinctive non-default setpoint (pre-reset)")
|
|
ap.add_argument("--b7-check", action="store_true",
|
|
help="B7 verify distinctive setpoint survived reset, restore")
|
|
ap.add_argument("--mark", type=float, default=7.5,
|
|
help="distinctive setpoint value (A), must be non-default")
|
|
args = ap.parse_args()
|
|
addr = int(args.addr, 0)
|
|
b7_mode = ("pre" if args.b7_pre else "post" if args.b7_post else
|
|
"mark" if args.b7_mark else "check" if args.b7_check else None)
|
|
|
|
ck = Checks()
|
|
log("iec104 master client -> %s:%d mode=%s"
|
|
% (args.ip, args.port, b7_mode or "b6"))
|
|
|
|
try:
|
|
m = Master(args.ip, args.port)
|
|
except Exception as e:
|
|
log("CONNECT FAIL: %r" % (e,))
|
|
return 2
|
|
|
|
rc = 2
|
|
try:
|
|
# 1. STARTDT
|
|
m._send(u_frame(U_STARTDT_ACT), "U STARTDT act")
|
|
ck.add("startdt_con", m.wait_event(
|
|
lambda e: e[0] == "startdt_con", 5.0) is not None)
|
|
|
|
if b7_mode is not None:
|
|
if b7_mode == "pre":
|
|
b7_pre(m, ck, args)
|
|
elif b7_mode == "post":
|
|
b7_post(m, ck, args)
|
|
elif b7_mode == "mark":
|
|
b7_mark(m, ck, args)
|
|
else:
|
|
b7_check(m, ck, args)
|
|
m.close()
|
|
rc = 0 if all(ok for _, ok, _ in ck.items) else 1
|
|
n_ok = sum(1 for _, ok, _ in ck.items)
|
|
log("SUMMARY: %d/%d checks PASS -> %s"
|
|
% (n_ok, len(ck.items), "PASS" if rc == 0 else "FAIL"))
|
|
return rc
|
|
|
|
# 2. first general interrogation
|
|
m.send_i(asdu(T_C_IC_NA_1, 1, COT_ACT, 0, ioa=0,
|
|
payload=bytes([RQT_GROUP1])),
|
|
"I C_IC act (QOI=20)")
|
|
ck.add("gi_actcon", m.wait_event(actcon_of(T_C_IC_NA_1, False),
|
|
5.0) is not None)
|
|
|
|
def scan_yx(ioa):
|
|
return lambda e: (e[0] == "yx" and e[1][0] == ioa
|
|
and e[1][3] in SCAN_COTS)
|
|
|
|
ck.add("scan_yx_dp(ioa4)", m.wait_event(scan_yx(4), 10.0) is not None)
|
|
ck.add("scan_yx_sp(ioa16)", m.wait_event(scan_yx(16), 10.0) is not None)
|
|
ck.add("scan_yc(ioa0x4018)", m.wait_event(
|
|
lambda e: (e[0] == "yc" and e[1][0] == 0x4018
|
|
and e[1][3] in SCAN_COTS), 10.0) is not None)
|
|
ck.add("scan_freq(ioa0x401D)", m.wait_event(
|
|
lambda e: (e[0] == "yc" and e[1][0] == 0x401D
|
|
and e[1][3] in SCAN_COTS), 10.0) is not None)
|
|
ck.add("gi_actterm", m.wait_event(
|
|
lambda e: (e[0] == "asdu" and e[1][0] == T_C_IC_NA_1
|
|
and e[1][1] == COT_ACTTERM), 10.0) is not None)
|
|
|
|
v16 = m.yx.get(16)
|
|
ck.add("baseline_yx16_off", v16 is not None and v16 == (0, False),
|
|
"yx16=%r" % (v16,))
|
|
|
|
# 3. COV up: lower OC1 setting (two-step C_SE_NC_1)
|
|
def se(select):
|
|
pl = struct.pack("<f", args.low) + bytes([0x80 if select else 0x00])
|
|
return asdu(T_C_SE_NC_1, 1, COT_ACT, 0, ioa=addr, payload=pl)
|
|
|
|
m.send_i(se(True), "I C_SE select 0x%X = %.3f" % (addr, args.low))
|
|
ck.add("se_select_con", m.wait_event(actcon_of(T_C_SE_NC_1, False),
|
|
5.0) is not None)
|
|
m.send_i(se(False), "I C_SE execute 0x%X = %.3f" % (addr, args.low))
|
|
ck.add("se_execute_con", m.wait_event(actcon_of(T_C_SE_NC_1, False),
|
|
5.0) is not None)
|
|
m.events.clear() # drop any echo/ack noise before COV watch
|
|
|
|
def spont(ioa, val):
|
|
return lambda e: (e[0] == "yx" and e[1][0] == ioa
|
|
and e[1][1] == val and e[1][3] == COT_SPONT)
|
|
|
|
ck.add("spont_yx16_ON", m.wait_event(spont(16, 1), 8.0) is not None)
|
|
ck.add("spont_yx15_ON", m.wait_event(spont(15, 1), 3.0) is not None)
|
|
|
|
# 4. COV down: restore OC1 default setting
|
|
def se_restore(select):
|
|
pl = struct.pack("<f", args.restore) + bytes([0x80 if select else 0x00])
|
|
return asdu(T_C_SE_NC_1, 1, COT_ACT, 0, ioa=addr, payload=pl)
|
|
|
|
m.send_i(se_restore(True), "I C_SE select 0x%X = %.3f (restore)"
|
|
% (addr, args.restore))
|
|
ck.add("se_restore_select_con",
|
|
m.wait_event(actcon_of(T_C_SE_NC_1, False), 5.0) is not None)
|
|
m.send_i(se_restore(False), "I C_SE execute 0x%X = %.3f (restore)"
|
|
% (addr, args.restore))
|
|
ck.add("se_restore_execute_con",
|
|
m.wait_event(actcon_of(T_C_SE_NC_1, False), 5.0) is not None)
|
|
m.events.clear()
|
|
|
|
got_spont_off = m.wait_event(spont(16, 0), 5.0) is not None
|
|
if got_spont_off:
|
|
ck.add("yx16_OFF", True, "via spontaneous COT=3")
|
|
else:
|
|
# floating-input chatter may have returned YX16 before the
|
|
# restore write; fall back to a second GI background scan.
|
|
log("no spont OFF within 5s -> second GI background scan")
|
|
m.send_i(asdu(T_C_IC_NA_1, 1, COT_ACT, 0, ioa=0,
|
|
payload=bytes([RQT_GROUP1])),
|
|
"I C_IC act #2 (QOI=20)")
|
|
ok_term = m.wait_event(
|
|
lambda e: (e[0] == "asdu" and e[1][0] == T_C_IC_NA_1
|
|
and e[1][1] == COT_ACTTERM), 15.0) is not None
|
|
ck.add("yx16_OFF", ok_term and m.yx.get(16) == (0, False),
|
|
"via 2nd GI scan, yx16=%r" % (m.yx.get(16),))
|
|
|
|
# 5. YK11 two-step (positive confirm; reset consumed by protectTask)
|
|
m.send_i(asdu(T_C_DC_NA_1, 1, COT_ACT, 0, ioa=0x600A,
|
|
payload=bytes([0x82])), "I C_DC YK11 select (QDC=82H)")
|
|
ck.add("yk11_select_con", m.wait_event(actcon_of(T_C_DC_NA_1, False),
|
|
5.0) is not None)
|
|
m.send_i(asdu(T_C_DC_NA_1, 1, COT_ACT, 0, ioa=0x600A,
|
|
payload=bytes([0x02])), "I C_DC YK11 execute (QDC=02H)")
|
|
ck.add("yk11_execute_con", m.wait_event(actcon_of(T_C_DC_NA_1, False),
|
|
5.0) is not None)
|
|
|
|
# 6. C_RD read back the restored setpoint
|
|
m.send_i(asdu(T_C_RD_NA_1, 1, COT_REQ, 0, ioa=addr),
|
|
"I C_RD 0x%X" % addr)
|
|
e = m.wait_event(lambda e: (e[0] == "yc" and e[1][0] == addr
|
|
and e[1][3] == COT_REQ), 5.0)
|
|
ck.add("rd_setpoint_10.0",
|
|
e is not None and abs(e[1][1] - args.restore) < 0.1,
|
|
"read=%.3f" % (e[1][1],) if e else "no response")
|
|
|
|
m.close()
|
|
rc = 0 if all(ok for _, ok, _ in ck.items) else 1
|
|
except Exception as e:
|
|
log("PROTOCOL ERROR: %r" % (e,))
|
|
rc = 2
|
|
|
|
n_ok = sum(1 for _, ok, _ in ck.items)
|
|
log("SUMMARY: %d/%d checks PASS -> %s"
|
|
% (n_ok, len(ck.items), "PASS" if rc == 0 else ("FAIL" if rc == 1 else "ERROR")))
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|