--- a/iec60870/iec60870.py Tue May 26 13:50:51 2026 +0200
+++ b/iec60870/iec60870.py Tue Jun 02 09:02:31 2026 +0200
@@ -183,9 +183,8 @@
"QueryLog (request archive file)"),
-# XSD fragment for the shared IEC 60870-5-104 connection parameters
-# (used in both server and client node XSD definitions)
-_IEC60870_CONN_PARAMS_XSD = """\
+# XSD: TCP/APCI link-layer parameters (one CS104 listener per server instance) +_IEC60870_APCI_PARAMS_XSD = """\ <xsd:attribute name="APCI_k" use="optional" default="12">
<xsd:restriction base="xsd:integer">
@@ -235,6 +234,10 @@
<xsd:attribute name="Use_TLS" type="xsd:boolean" use="optional" default="false"/>
+# XSD: application-layer ASDU encoding per common address (station unit) +_IEC60870_APP_LAYER_PARAMS_XSD = """\ <xsd:attribute name="CA_Size" use="optional" default="2">
<xsd:restriction base="xsd:integer">
@@ -263,6 +266,9 @@
<xsd:attribute name="Use_Local_Timezone" type="xsd:boolean" use="optional" default="true"/>
+# Client node still uses the combined fragment (hidden from IDE) +_IEC60870_CONN_PARAMS_XSD = _IEC60870_APCI_PARAMS_XSD + _IEC60870_APP_LAYER_PARAMS_XSD def _srv_attr_map(server_plug):
for element in server_plug.GetParamsAttributes():
@@ -271,6 +277,24 @@
+def _ca_attr_map(ca_plug): + for element in ca_plug.GetParamsAttributes(): + if element["name"] == "IEC60870CommonAddressNode": + return {c["name"]: c["value"] for c in element["children"]} +def _app_layer_signature(cmap): + """Tuple used to ensure one CS104 slave uses consistent ASDU encoding.""" + cot_has_oa = str(cmap.get("COT_Has_OA", True)).lower() in ("true", "1", "yes") + int(cmap.get("CA_Size", 2)), + int(cmap.get("IOA_Size", 3)), + 1 if cot_has_oa else 0, + int(cmap.get("OA_Value", 10)), return s.replace("\\", "\\\\").replace("\"", "\\\"")
@@ -554,8 +578,8 @@
%%MD…0 / %%MD…1 / %%MX…2 directly under srv (length len(srv_loc)+1).
ConfigTreeNode.GetLocations() uses prefix matching on LOC tuples, so a
- datapoint at e.g. …4.0.0 incorrectly receives %%MD4.0.0 alongside %%MX4.0.0.0.
- Datapoint IOAs live one segment deeper (…4.0.0.<ioa>), so they never match.
+ datapoint at e.g. …4.0.0.0 incorrectly receives %%MD4.0.0 alongside %%MX4.0.0.0.0. + Datapoint IOAs live two segments deeper (…4.0.0.<ioa>), so they never match. if not srv_loc or not loc_tuple:
@@ -670,6 +694,10 @@
ioa = self.GetParamsAttributes()[0]["children"][1]["value"]
count = self.GetParamsAttributes()[0]["children"][2]["value"]
asdu_type_str = self.GetParamsAttributes()[0]["children"][0]["value"]
+ parent = getattr(self, "CTNParent", None) + if parent is not None and getattr(parent, "PlugType", None) == "IEC60870CommonAddress": + common_label = str(parent.IEC60870CommonAddressNode.getCommon_Address()) type_id, datatype, datasize, direction, size_code, desc = \
iec60870_asdu_types[asdu_type_str]
@@ -678,12 +706,16 @@
for offset in range(ioa, ioa + count):
+ ioa_name = desc + " IOA " + str(offset) + ioa_name = "CA " + common_label + " — " + ioa_name - "name": desc + " IOA " + str(offset),
- "var_name": "IEC60870_" + str(type_id) + "_" + str(offset),
+ "var_name": "IEC60870_" + str(type_id) + ( + ("_CA" + common_label) if common_label else "") + "_" + str(offset), "location": size_code + ".".join(
[str(i) for i in current_location]) + "." + str(offset),
@@ -700,6 +732,67 @@
+# C O M M O N A D D R E S S +class _CommonAddressPlug(object): + XSD = ("""<?xml version="1.0" encoding="ISO-8859-1" ?> + <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> + <xsd:element name="IEC60870CommonAddressNode"> + <xsd:attribute name="Configuration_Name" type="xsd:string" use="optional" default=""/> + <xsd:attribute name="Common_Address" use="optional" default="1"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="65534"/> + + _IEC60870_APP_LAYER_PARAMS_XSD + + CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")] + PlugType = "IEC60870CommonAddress" + loc_str = ".".join(map(str, self.GetCurrentLocation())) + common = self.IEC60870CommonAddressNode.getCommon_Address() + self.IEC60870CommonAddressNode.setConfiguration_Name( + "Common Address %s (%s)" % (common, loc_str)) + def GetNodeCount(self): + def GetConfigName(self): + return self.IEC60870CommonAddressNode.getConfiguration_Name() + def GetVariableLocationTree(self): + current_location = self.GetCurrentLocation() + name = self.BaseParams.getName() + common = self.IEC60870CommonAddressNode.getCommon_Address() + for child in self.IECSortedChildren(): + entries.append(child.GetVariableLocationTree()) + "type": LOCATION_CONFNODE, + [str(i) for i in current_location]) + ".x", + "description": "Common Address %s" % common, + def CTNGenerate_C(self, buildpath, locations): # C O N T R O L L E D S T A T I O N (Server)
@@ -711,23 +804,16 @@
<xsd:attribute name="Configuration_Name" type="xsd:string" use="optional" default=""/>
<xsd:attribute name="Local_IP_Address" type="xsd:string" use="optional" default="#ANY#"/>
<xsd:attribute name="Local_Port_Number" type="xsd:string" use="optional" default="2404"/>
- <xsd:attribute name="Common_Address" use="optional" default="1">
- <xsd:restriction base="xsd:integer">
- <xsd:minInclusive value="1"/>
- <xsd:maxInclusive value="65534"/>
- + _IEC60870_CONN_PARAMS_XSD +
+ + _IEC60870_APCI_PARAMS_XSD + - CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")]
+ ("IEC60870CommonAddress", _CommonAddressPlug, "Common Address")] PlugType = "IEC60870Server"
@@ -1000,6 +1086,7 @@
diag_intrinsic_global_lines = []
diag_matiec_weak_seen = set()
@@ -1047,8 +1134,42 @@
if len(loose_locs) > len(project_locs or []):
project_locs = loose_locs
for server_id, srv in enumerate(servers):
smap = _srv_attr_map(srv)
+ ca_nodes = srv.IECSortedChildren() + c for c in ca_nodes if getattr(c, "CTNType", None) == "IEC60870DataPoint"] + _("Error: IEC60870 server %%{a1}.x has data points attached " + "directly to the server. Add one or more Common Address " + "nodes and move data points underneath them.\n").format( + a1=_lt_to_str(srv.GetCurrentLocation()))) + for ca_node in ca_nodes: + cmap = _ca_attr_map(ca_node) + common = int(cmap.get("Common_Address", 1)) + if common in seen_common: + _("Error: IEC60870 server %%{a1}.x has duplicate " + "Common_Address {a2} on nodes %%{a3}.x and another " + "Common Address child.\n").format( + a1=_lt_to_str(srv.GetCurrentLocation()), + a3=_lt_to_str(ca_node.GetCurrentLocation()))) + seen_common.add(common) + app_layer_sigs.add(_app_layer_signature(cmap)) + if len(app_layer_sigs) > 1: + _("Error: IEC60870 server %%{a1}.x: all Common Address " + "children must use the same CA_Size, IOA_Size, " + "COT_Has_OA, and OA_Value (one CS104 listener shares " + "application-layer encoding).\n").format( + a1=_lt_to_str(srv.GetCurrentLocation()))) rd_name, wr_name, conn_name, diag_mode = _resolve_server_diagnostic_names(
srv, locations, project_locs)
@@ -1102,12 +1223,6 @@
ip_c = _c_escape_str(str(ip))
port = int(smap.get("Local_Port_Number", 2404))
- common = int(smap.get("Common_Address", 1))
- ca_sz = int(smap.get("CA_Size", 2))
- ioa_sz = int(smap.get("IOA_Size", 3))
- cot_has_oa = str(smap.get("COT_Has_OA", True)).lower() in (
- oa = int(smap.get("OA_Value", 10))
apci_k = int(smap["APCI_k"])
apci_w = int(smap["APCI_w"])
t0 = int(smap["Timeout_t0"])
@@ -1115,13 +1230,25 @@
t2 = int(smap["Timeout_t2"])
t3 = int(smap["Timeout_t3"])
+ first_cmap = _ca_attr_map(ca_nodes[0]) + srv_ca_sz = int(first_cmap.get("CA_Size", 2)) + srv_ioa_sz = int(first_cmap.get("IOA_Size", 3)) + srv_cot_has_oa = str(first_cmap.get("COT_Has_OA", True)).lower() in ( + srv_oa = int(first_cmap.get("OA_Value", 10)) loc_label = _c_escape_str(
".".join(map(str, srv.GetCurrentLocation())))
{ .loc_label = "%(loc)s",
- .common_address = %(co)d,
@@ -1138,14 +1265,13 @@
- "cotoa": 1 if cot_has_oa else 0,
+ "cotoa": 1 if srv_cot_has_oa else 0, @@ -1157,71 +1283,107 @@
- for dp in srv.IECSortedChildren():
- asdu_str = _data_point_ct(dp, 0)
- ioa0 = int(_data_point_ct(dp, 1))
- npoints = int(_data_point_ct(dp, 2))
- tid, _dt, _ds, direction, _sc, _d = iec60870_asdu_types[asdu_str]
- is_cmd = 1 if direction == "Q" else 0
- srv_loc_tuple = tuple(srv.GetCurrentLocation())
- for iecvar in dp.GetLocations():
- loc_t = _normalize_loc_tuple(loc)
- if _iec60870_loc_is_server_intrinsic_diag_slot(
- if ioa_v < ioa0 or ioa_v >= ioa0 + npoints:
- nm = str(iecvar["NAME"]).strip()
- iet = iecvar["IEC_TYPE"]
- bk = iec_iec_type_to_bind_kind(iet)
- bind_idx = len(bindings_rows)
- c_stor = _iec_c_storage_type(iet)
- if nm in srv_diag_scalars:
- weak_bk = diag_weak_backing_by_ptrsym.get(nm)
- _("IEC60870 internal: diagnostic %(sym)s has "
- "no generated backing.\n") % {"sym": nm})
+ for ca_node in ca_nodes: + cmap = _ca_attr_map(ca_node) + common = int(cmap.get("Common_Address", 1)) + ca_sz = int(cmap.get("CA_Size", 2)) + ioa_sz = int(cmap.get("IOA_Size", 3)) + cot_has_oa = str(cmap.get("COT_Has_OA", True)).lower() in ( + oa = int(cmap.get("OA_Value", 10)) + ca_loc_label = _c_escape_str( + ".".join(map(str, ca_node.GetCurrentLocation()))) + { .server_index = %(sid)d, + .loc_label = "%(loc)s", + .common_address = %(co)d, + .cot_two_byte = %(cotoa)d, + "cotoa": 1 if cot_has_oa else 0, + this_ca_index = ca_index + for dp in ca_node.IECSortedChildren(): + asdu_str = _data_point_ct(dp, 0) + ioa0 = int(_data_point_ct(dp, 1)) + npoints = int(_data_point_ct(dp, 2)) + tid, _dt, _ds, direction, _sc, _d = iec60870_asdu_types[asdu_str] + is_cmd = 1 if direction == "Q" else 0 + srv_loc_tuple = tuple(srv.GetCurrentLocation()) + for iecvar in dp.GetLocations(): + loc_t = _normalize_loc_tuple(loc) + if _iec60870_loc_is_server_intrinsic_diag_slot( + if ioa_v < ioa0 or ioa_v >= ioa0 + npoints: + nm = str(iecvar["NAME"]).strip() + iet = iecvar["IEC_TYPE"] + bk = iec_iec_type_to_bind_kind(iet) + bind_idx = len(bindings_rows) + c_stor = _iec_c_storage_type(iet) + if nm in srv_diag_scalars: + weak_bk = diag_weak_backing_by_ptrsym.get(nm) + _("IEC60870 internal: diagnostic %(sym)s has " + "no generated backing.\n") % {"sym": nm}) + " { %(caid)d, %(tid)d, %(ioa)d, %(isc)d, %(bkind)d, " + "(void *)&%(weak_bk)s }," % { + stor_name = "iec60870_plcmem_%s_%d" % (loc_prefix, bind_idx) + zinit = _iec_c_storage_zero_init(c_stor) + plc_memory_storage_lines.append( + "static %(ct)s %(sn)s = %(z)s;\n" % { + "ct": c_stor, "sn": stor_name, "z": zinit}) + if nm not in loc_vars_seen: + "%(ct)s *%(nm)s = &%(sn)s;\n" % { + "ct": c_stor, "nm": nm, "sn": stor_name}) - " { %(sid)d, %(tid)d, %(ioa)d, %(isc)d, %(bkind)d, "
- "(void *)&%(weak_bk)s }," % {
+ " { %(caid)d, %(tid)d, %(ioa)d, %(isc)d, %(bk)d, " + "(void *)&%(sn)s }," % {
- stor_name = "iec60870_plcmem_%s_%d" % (loc_prefix, bind_idx)
- zinit = _iec_c_storage_zero_init(c_stor)
- plc_memory_storage_lines.append(
- "static %(ct)s %(sn)s = %(z)s;\n" % {
- "ct": c_stor, "sn": stor_name, "z": zinit})
- if nm not in loc_vars_seen:
- "%(ct)s *%(nm)s = &%(sn)s;\n" % {
- "ct": c_stor, "nm": nm, "sn": stor_name})
- " { %(sid)d, %(tid)d, %(ioa)d, %(isc)d, %(bk)d, "
- "(void *)&%(sn)s }," % {
+ " { 0, \"\", 1, 2, 3, 1, 10 }, /* placeholder */") " { -1, 0, 0, 0, 0, NULL }, /* placeholder */")
@@ -1232,6 +1394,9 @@
"static iec60870_srv_t iec60870_srv[IEC60870_NUM_SERVERS_%s] = {%s\n};"
% (loc_prefix, "".join(srv_rows)))
+ "static iec60870_ca_t iec60870_ca[IEC60870_NUM_CA_%s] = {%s\n};" + % (loc_prefix, "".join(ca_rows))) diag_intrinsic_global_block = "".join(diag_intrinsic_global_lines)
if diag_intrinsic_global_block:
@@ -1262,7 +1427,9 @@
"extern_block": extern_block,
"bindings_rows": bindings_body,
"num_servers": str(num_srv),
"num_bindings": str(num_bind),
"max_remote_clients": str(max_remote),
--- a/iec60870/iec60870_runtime.c Tue May 26 13:50:51 2026 +0200
+++ b/iec60870/iec60870_runtime.c Tue Jun 02 09:02:31 2026 +0200
@@ -35,7 +35,7 @@
%(plc_memory_storage_block)s
struct iec60870_binding {
@@ -44,8 +44,17 @@
@@ -94,8 +103,16 @@
static const size_t iec60870_binding_count = IEC60870_NUM_BINDINGS_%(locstr)s;
+static iec60870_srv_t *iec60870_srv_for_ca(int ca_index) { + if (ca_index < 0 || ca_index >= IEC60870_NUM_CA_%(locstr)s) + return &iec60870_srv[iec60870_ca[ca_index].server_index]; static void iec_rd_inc(iec60870_srv_t *s) {
@@ -310,7 +327,7 @@
iec_wall_ref_maybe_refresh();
for (i = 0; i < iec60870_binding_count; i++) {
- if (iec60870_bindings[i].server_index < 0 || !iec60870_bindings[i].iec_var)
+ if (iec60870_bindings[i].ca_index < 0 || !iec60870_bindings[i].iec_var) iec_binding_last_mono_ms[i] = now_mono;
iec_binding_shadow_store(i, &iec60870_bindings[i]);
@@ -323,7 +340,7 @@
for (i = 0; i < iec60870_binding_count; i++) {
struct iec60870_binding *b = &iec60870_bindings[i];
- if (b->server_index < 0 || !b->iec_var)
+ if (b->ca_index < 0 || !b->iec_var) if (iec_binding_shadow_differs(i, b))
@@ -356,8 +373,7 @@
struct sCP24Time2a cp24_z;
struct sCP16Time2a cp16_z;
size_t bind_idx = (size_t)(b - iec60870_bindings);
- (b->server_index >= 0) ? &iec60870_srv[b->server_index] : NULL;
+ iec60870_srv_t *srv = iec60870_srv_for_ca(b->ca_index); uint64_t tag_ms = iec_binding_tag_ms(srv, bind_idx);
struct sBinaryCounterReading bcr_st;
BinaryCounterReading bcr;
@@ -580,16 +596,17 @@
-static bool iec_send_monitor(IMasterConnection conn, iec60870_srv_t *srv, struct iec60870_binding *b) {
+static bool iec_send_monitor(IMasterConnection conn, iec60870_ca_t *ca, struct iec60870_binding *b) { + iec60870_srv_t *srv = iec60870_srv_for_ca(b->ca_index); CS101_AppLayerParameters al = CS104_Slave_getAppLayerParameters(srv->slave);
- int oa = srv->cot_two_byte ? srv->oa : 0;
+ int oa = ca->cot_two_byte ? ca->oa : 0; InformationObject io = iec_make_monitor_io(b->type_id, b->ioa, b);
CS101_ASDU asdu = CS101_ASDU_create(al, false, CS101_COT_INTERROGATED_BY_STATION, oa,
- srv->common_address, false, false);
+ ca->common_address, false, false); InformationObject_destroy(io);
@@ -609,21 +626,25 @@
static bool iec_interrogation_handler(void *parameter, IMasterConnection connection, CS101_ASDU asdu, uint8_t qoi) {
iec60870_srv_t *srv = (iec60870_srv_t *)parameter;
- size_t idx = (size_t)(srv - iec60870_srv);
+ size_t srv_idx = (size_t)(srv - iec60870_srv); + int asdu_ca = CS101_ASDU_getCA(asdu); if (qoi == IEC60870_QOI_STATION) {
for (i = 0; i < iec60870_binding_count; i++) {
struct iec60870_binding *b = &iec60870_bindings[i];
- if (b->server_index < 0)
- if ((size_t)b->server_index != idx)
+ ca = &iec60870_ca[b->ca_index]; + if ((size_t)ca->server_index != srv_idx) + if (ca->common_address != asdu_ca) - iec_send_monitor(connection, srv, b);
+ iec_send_monitor(connection, ca, b); @@ -648,6 +669,7 @@
TypeID tid = CS101_ASDU_getTypeID(asdu);
InformationObject io = CS101_ASDU_getElement(asdu, 0);
size_t srv_idx = (size_t)(srv - iec60870_srv);
+ int asdu_ca = CS101_ASDU_getCA(asdu); struct iec60870_binding *match = NULL;
@@ -658,9 +680,13 @@
int ioa = InformationObject_getObjectAddress(io);
for (i = 0; i < iec60870_binding_count; i++) {
- if (iec60870_bindings[i].server_index < 0)
+ if (iec60870_bindings[i].ca_index < 0) - if ((size_t)iec60870_bindings[i].server_index != srv_idx)
+ ca = &iec60870_ca[iec60870_bindings[i].ca_index]; + if ((size_t)ca->server_index != srv_idx) + if (ca->common_address != asdu_ca) if (!iec60870_bindings[i].is_command)
@@ -891,7 +917,11 @@
for (bi = 0; bi < iec60870_binding_count; bi++) {
- if (iec60870_bindings[bi].server_index != si)
+ if (iec60870_bindings[bi].ca_index < 0) + ca = &iec60870_ca[iec60870_bindings[bi].ca_index]; + if ((size_t)ca->server_index != (size_t)si) if (!iec60870_bindings[bi].is_command)