--- a/LPCExtension.py Fri Jan 30 08:41:19 2026 +0100
+++ b/LPCExtension.py Fri Apr 10 11:20:01 2026 +0200
@@ -48,6 +48,7 @@
('lpchmi', _('Smarteh HMI'), _('Create customized HMI'), 'lpchmi.LPCHMI'),
('bacnet', _('Bacnet support'), _('Map located variables over Bacnet'), 'LPCBACnet.RootClass'),
('modbus', _('Modbus'), _('Map located variables over Modbus'), 'LPCModbus.RootClass'),
+ ('iec60870', _('IEC 60870-5-104'), _('Map located variables over IEC 60870-5-104'), 'LPCIEC60870.RootClass'), ('mqtt', _('MQTT client'), _('Map MQTT topics as located variables'), 'LPCMQTT.MQTTClient'),
('LPCBus', _('LPC bus'), _('Support for Smarteh modules'), 'LPCBus.LPCBus'),
('CanOpen', _('CANOpen'), _('Support for CANopen'), 'LPCCanFestival.LPCCanOpen')]
@@ -167,7 +168,7 @@
old_ThirdPartyPath = paths.ThirdPartyPath
def ThirdPartyPath(name, *args):
- if name in ["BACnet", "Modbus", "CanFestival-3"]:
+ if name in ["BACnet", "Modbus", "CanFestival-3", "IEC60870"]: res = old_ThirdPartyPath(name)
res = os.path.join(res, arch)
elif name == "paho.mqtt.c":
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/iec60870/__init__.py Fri Apr 10 11:20:01 2026 +0200
@@ -0,0 +1,3 @@
+from __future__ import absolute_import --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/iec60870/iec60870.py Fri Apr 10 11:20:01 2026 +0200
@@ -0,0 +1,472 @@
+from __future__ import absolute_import +from ConfigTreeNode import ConfigTreeNode +from PLCControler import LOCATION_CONFNODE, LOCATION_VAR_INPUT, \ + LOCATION_VAR_OUTPUT, LOCATION_VAR_MEMORY +# (type_id, IEC_type, datasize, direction, size_code, description) +# direction: "I" for monitor (input), "Q" for control (output), "M" for memory +# size_code: "X" for bit, "B" for byte, "W" for word, "D" for dword + "M_SP_NA_1 - Single point": (1, "BOOL", 1, "I", "X", "Single Point Information"), + "M_DP_NA_1 - Double point": (3, "BYTE", 8, "I", "B", "Double Point Information"), + "M_ST_NA_1 - Step position": (5, "INT", 16, "I", "W", "Step Position Information"), + "M_ME_NA_1 - Measured normalized": (9, "WORD", 16, "I", "W", "Measured Value Normalized"), + "M_ME_NB_1 - Measured scaled": (11, "INT", 16, "I", "W", "Measured Value Scaled"), + "M_ME_NC_1 - Measured float": (13, "REAL", 32, "I", "D", "Measured Value Short Float"), + "C_SC_NA_1 - Single command": (45, "BOOL", 1, "Q", "X", "Single Command"), + "C_DC_NA_1 - Double command": (46, "BYTE", 8, "Q", "B", "Double Command"), + "C_RC_NA_1 - Step command": (47, "BYTE", 8, "Q", "B", "Regulating Step Command"), + "C_SE_NA_1 - Setpoint normalized": (48, "WORD", 16, "Q", "W", "Set Point Normalized"), + "C_SE_NB_1 - Setpoint scaled": (49, "INT", 16, "Q", "W", "Set Point Scaled"), + "C_SE_NC_1 - Setpoint float": (50, "REAL", 32, "Q", "D", "Set Point Short Float"), + "I": LOCATION_VAR_INPUT, + "Q": LOCATION_VAR_OUTPUT, + "M": LOCATION_VAR_MEMORY, +# 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:attribute name="APCI_k" use="optional" default="12"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="32767"/> + <xsd:attribute name="APCI_w" use="optional" default="8"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="32767"/> + <xsd:attribute name="Timeout_t0" use="optional" default="10"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="255"/> + <xsd:attribute name="Timeout_t1" use="optional" default="15"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="255"/> + <xsd:attribute name="Timeout_t2" use="optional" default="10"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="255"/> + <xsd:attribute name="Timeout_t3" use="optional" default="20"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="255"/> + <xsd:attribute name="Use_TLS" type="xsd:boolean" use="optional" default="false"/> + <xsd:attribute name="CA_Size" use="optional" default="2"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="2"/> + <xsd:attribute name="IOA_Size" use="optional" default="3"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="3"/> + <xsd:attribute name="COT_Has_OA" type="xsd:boolean" use="optional" default="true"/> + <xsd:attribute name="OA_Value" use="optional" default="10"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="0"/> + <xsd:maxInclusive value="255"/> + <xsd:attribute name="Use_Local_Timezone" type="xsd:boolean" use="optional" default="true"/> +class _DataPointPlug(object): + XSD = """<?xml version="1.0" encoding="ISO-8859-1" ?> + <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> + <xsd:element name="IEC60870DataPoint"> + <xsd:attribute name="ASDU_Type" type="xsd:string" use="optional" default="M_SP_NA_1 - Single point"/> + <xsd:attribute name="IOA" use="optional" default="0"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="0"/> + <xsd:maxInclusive value="16777215"/> + <xsd:attribute name="Nr_of_Points" use="optional" default="1"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="1"/> + <xsd:maxInclusive value="65535"/> + def GetParamsAttributes(self, path=None): + infos = ConfigTreeNode.GetParamsAttributes(self, path=path) + if element["name"] == "IEC60870DataPoint": + for child in element["children"]: + if child["name"] == "ASDU_Type": + _list = sorted(iec60870_asdu_types.keys()) + def GetVariableLocationTree(self): + current_location = self.GetCurrentLocation() + name = self.BaseParams.getName() + ioa = self.GetParamsAttributes()[0]["children"][1]["value"] + count = self.GetParamsAttributes()[0]["children"][2]["value"] + asdu_type_str = self.GetParamsAttributes()[0]["children"][0]["value"] + type_id, datatype, datasize, direction, size_code, desc = \ + iec60870_asdu_types[asdu_type_str] + loc_type = LOCATION_TYPES[direction] + for offset in range(ioa, ioa + count): + "name": desc + " IOA " + str(offset), + "var_name": "IEC104_" + str(type_id) + "_" + str(offset), + "location": size_code + ".".join( + [str(i) for i in current_location]) + "." + str(offset), + "type": LOCATION_CONFNODE, + [str(i) for i in current_location]) + ".x", + def CTNGenerate_C(self, buildpath, locations): +# C O N T R O L L E D S T A T I O N (Server) +class _IEC60870ServerPlug(object): + XSD = ("""<?xml version="1.0" encoding="ISO-8859-1" ?> + <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> + <xsd:element name="IEC60870ServerNode"> + <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 + + CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")] + PlugType = "IEC60870Server" + loc_str = ".".join(map(str, self.GetCurrentLocation())) + self.IEC60870ServerNode.setConfiguration_Name( + "IEC60870 Server " + loc_str) + def GetNodeCount(self): + def GetConfigName(self): + return self.IEC60870ServerNode.getConfiguration_Name() + def GetIPServerPortNumbers(self): + port = self.IEC60870ServerNode.getLocal_Port_Number() + addr = self.IEC60870ServerNode.getLocal_IP_Address() + return [(self.GetCurrentLocation(), addr, port)] + def GetVariableLocationTree(self): + current_location = self.GetCurrentLocation() + name = self.BaseParams.getName() + "name": "Read Request Counter", + "type": LOCATION_VAR_MEMORY, + "var_name": "var_name", + "location": "D" + ".".join( + [str(i) for i in current_location]) + ".0", + "description": "IEC60870 read request counter", + "name": "Write Request Counter", + "type": LOCATION_VAR_MEMORY, + "var_name": "var_name", + "location": "D" + ".".join( + [str(i) for i in current_location]) + ".1", + "description": "IEC60870 write request counter", + "name": "Connection Active Flag", + "type": LOCATION_VAR_MEMORY, + "var_name": "var_name", + "location": "X" + ".".join( + [str(i) for i in current_location]) + ".2", + "description": "IEC60870 connection active flag", + for child in self.IECSortedChildren(): + entries.append(child.GetVariableLocationTree()) + "type": LOCATION_CONFNODE, + [str(i) for i in current_location]) + ".x", + def CTNGenerate_C(self, buildpath, locations): +# C O N T R O L L I N G S T A T I O N (Client) +class _IEC60870ClientPlug(object): + XSD = ("""<?xml version="1.0" encoding="ISO-8859-1" ?> + <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> + <xsd:element name="IEC60870ClientNode"> + <xsd:attribute name="Configuration_Name" type="xsd:string" use="optional" default=""/> + <xsd:attribute name="Remote_IP_Address" type="xsd:string" use="optional" default="localhost"/> + <xsd:attribute name="Remote_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"/> + <xsd:attribute name="Polling_Interval_ms" use="optional" default="1000"> + <xsd:restriction base="xsd:unsignedLong"> + <xsd:minInclusive value="0"/> + <xsd:maxInclusive value="2147483647"/> + + _IEC60870_CONN_PARAMS_XSD + + CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")] + PlugType = "IEC60870Client" + loc_str = ".".join(map(str, self.GetCurrentLocation())) + self.IEC60870ClientNode.setConfiguration_Name( + "IEC60870 Client " + loc_str) + def GetNodeCount(self): + def GetConfigName(self): + return self.IEC60870ClientNode.getConfiguration_Name() + def GetVariableLocationTree(self): + current_location = self.GetCurrentLocation() + name = self.BaseParams.getName() + "name": "Connection Status", + "type": LOCATION_VAR_MEMORY, + "var_name": "var_name", + "location": "B" + ".".join( + [str(i) for i in current_location]) + ".0", + "description": "Connection status (0=disconnected, 1=connected, " + "2=connecting, 3=error)", + "name": "Interrogation Trigger", + "type": LOCATION_VAR_MEMORY, + "var_name": "var_name", + "location": "X" + ".".join( + [str(i) for i in current_location]) + ".1", + "description": "Trigger general interrogation", + for child in self.IECSortedChildren(): + entries.append(child.GetVariableLocationTree()) + "type": LOCATION_CONFNODE, + [str(i) for i in current_location]) + ".x", + def CTNGenerate_C(self, buildpath, locations): +def _lt_to_str(loctuple): + return '.'.join(map(str, loctuple)) +class RootClass(object): + XSD = """<?xml version="1.0" encoding="ISO-8859-1" ?> + <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> + <xsd:element name="IEC60870Root"> + <xsd:attribute name="MaxRemoteClients" use="optional" default="10"> + <xsd:restriction base="xsd:integer"> + <xsd:minInclusive value="0"/> + <xsd:maxInclusive value="65535"/> + ("IEC60870Server", _IEC60870ServerPlug, "IEC 60870-5-104 Server"), + ("IEC60870Client", _IEC60870ClientPlug, "IEC 60870-5-104 Client"), + def GetNodeCount(self): + max_remote_clients = self.GetParamsAttributes()[0]["children"][0]["value"] + total = (max_remote_clients, 0) + for child in self.IECSortedChildren(): + x1 + x2 for x1, x2 in zip(total, child.GetNodeCount())) + def GetIPServerPortNumbers(self): + for child in self.IECSortedChildren(): + if child.CTNType == "IEC60870Server": + port_numbers.extend(child.GetIPServerPortNumbers()) + def GetConfigNames(self): + for child in self.IECSortedChildren(): + (child.GetCurrentLocation(), child.GetConfigName())) + def CTNGenerate_C(self, buildpath, locations): + for CTNInstance in self.GetCTRoot().IterChildren(): + if CTNInstance.CTNType == "iec60870": + node_config_names.extend(CTNInstance.GetConfigNames()) + for i in range(0, len(node_config_names) - 1): + for j in range(i + 1, len(node_config_names)): + if node_config_names[i][1] == node_config_names[j][1]: + "Error: IEC60870 plugin nodes %%{a1}.x and %%{a2}.x " + "use the same Configuration_Name \"{a3}\".\n" + a1=_lt_to_str(node_config_names[i][0]), + a2=_lt_to_str(node_config_names[j][0]), + a3=node_config_names[j][1]) + self.FatalError(error_message) + for CTNInstance in self.GetCTRoot().IterChildren(): + if CTNInstance.CTNType == "iec60870": + ip_ports.extend(CTNInstance.GetIPServerPortNumbers()) + for loc1, addr1, port1 in ip_ports[:-1]: + for loc2, addr2, port2 in ip_ports[i:]: + if (port1 == port2) and ( + or (addr1 in ("", "*", "#ANY#")) + or (addr2 in ("", "*", "#ANY#")) + "Error: IEC60870 plugin nodes %%{a1}.x and %%{a2}.x " + "use same port number \"{a3}\" on the same " + "(or overlapping) network interfaces " + "\"{a4}\" and \"{a5}\".\n" + a1=_lt_to_str(loc1), a2=_lt_to_str(loc2), + a3=port1, a4=addr1, a5=addr2) + self.FatalError(error_message)