lpcmanager

iec60870: initial draft for the extension.
iec60870
2 months ago, Dino Kosic
bc72858a757c
Parents 27368b88c253
Children d263bbb72087
iec60870: initial draft for the extension.
--- 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 @@
arch = GetLPCArch()
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/LPCIEC60870.py Fri Apr 10 11:20:01 2026 +0200
@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from iec60870.iec60870 import RootClass
--- /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 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+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 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import os
+
+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
+iec60870_asdu_types = {
+ "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"),
+}
+
+LOCATION_TYPES = {
+ "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:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="32767"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="APCI_w" use="optional" default="8">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="32767"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Timeout_t0" use="optional" default="10">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="255"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Timeout_t1" use="optional" default="15">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="255"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Timeout_t2" use="optional" default="10">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="255"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Timeout_t3" use="optional" default="20">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="255"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Use_TLS" type="xsd:boolean" use="optional" default="false"/>
+ <xsd:attribute name="CA_Size" use="optional" default="2">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="2"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="IOA_Size" use="optional" default="3">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="3"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="COT_Has_OA" type="xsd:boolean" use="optional" default="true"/>
+ <xsd:attribute name="OA_Value" use="optional" default="10">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="0"/>
+ <xsd:maxInclusive value="255"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Use_Local_Timezone" type="xsd:boolean" use="optional" default="true"/>
+"""
+
+
+#
+# D A T A P O I N T
+#
+
+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:complexType>
+ <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:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="0"/>
+ <xsd:maxInclusive value="16777215"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Nr_of_Points" use="optional" default="1">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="65535"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ """
+
+ def GetParamsAttributes(self, path=None):
+ infos = ConfigTreeNode.GetParamsAttributes(self, path=path)
+ for element in infos:
+ if element["name"] == "IEC60870DataPoint":
+ for child in element["children"]:
+ if child["name"] == "ASDU_Type":
+ _list = sorted(iec60870_asdu_types.keys())
+ child["type"] = _list
+ return infos
+
+ 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]
+
+ entries = []
+ for offset in range(ioa, ioa + count):
+ entries.append({
+ "name": desc + " IOA " + str(offset),
+ "type": loc_type,
+ "size": datasize,
+ "IEC_type": datatype,
+ "var_name": "IEC104_" + str(type_id) + "_" + str(offset),
+ "location": size_code + ".".join(
+ [str(i) for i in current_location]) + "." + str(offset),
+ "description": desc,
+ "children": []})
+
+ return {"name": name,
+ "type": LOCATION_CONFNODE,
+ "location": ".".join(
+ [str(i) for i in current_location]) + ".x",
+ "children": entries}
+
+ def CTNGenerate_C(self, buildpath, locations):
+ return [], "", False
+
+
+#
+# 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:complexType>
+ <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:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="65534"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+"""
+ + _IEC60870_CONN_PARAMS_XSD +
+ """
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ """)
+
+ CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")]
+ PlugType = "IEC60870Server"
+
+ def __init__(self):
+ loc_str = ".".join(map(str, self.GetCurrentLocation()))
+ self.IEC60870ServerNode.setConfiguration_Name(
+ "IEC60870 Server " + loc_str)
+
+ def GetNodeCount(self):
+ return (1, 0)
+
+ 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()
+
+ entries = []
+ entries.append({
+ "name": "Read Request Counter",
+ "type": LOCATION_VAR_MEMORY,
+ "size": 32,
+ "IEC_type": "UDINT",
+ "var_name": "var_name",
+ "location": "D" + ".".join(
+ [str(i) for i in current_location]) + ".0",
+ "description": "IEC60870 read request counter",
+ "children": []})
+ entries.append({
+ "name": "Write Request Counter",
+ "type": LOCATION_VAR_MEMORY,
+ "size": 32,
+ "IEC_type": "UDINT",
+ "var_name": "var_name",
+ "location": "D" + ".".join(
+ [str(i) for i in current_location]) + ".1",
+ "description": "IEC60870 write request counter",
+ "children": []})
+ entries.append({
+ "name": "Connection Active Flag",
+ "type": LOCATION_VAR_MEMORY,
+ "size": 1,
+ "IEC_type": "BOOL",
+ "var_name": "var_name",
+ "location": "X" + ".".join(
+ [str(i) for i in current_location]) + ".2",
+ "description": "IEC60870 connection active flag",
+ "children": []})
+
+ for child in self.IECSortedChildren():
+ entries.append(child.GetVariableLocationTree())
+
+ return {"name": name,
+ "type": LOCATION_CONFNODE,
+ "location": ".".join(
+ [str(i) for i in current_location]) + ".x",
+ "children": entries}
+
+ def CTNGenerate_C(self, buildpath, locations):
+ return [], "", False
+
+
+#
+# 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:complexType>
+ <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:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="1"/>
+ <xsd:maxInclusive value="65534"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ <xsd:attribute name="Polling_Interval_ms" use="optional" default="1000">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:unsignedLong">
+ <xsd:minInclusive value="0"/>
+ <xsd:maxInclusive value="2147483647"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+"""
+ + _IEC60870_CONN_PARAMS_XSD +
+ """
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ """)
+
+ CTNChildrenTypes = [("IEC60870DataPoint", _DataPointPlug, "Data Point")]
+ PlugType = "IEC60870Client"
+
+ def __init__(self):
+ loc_str = ".".join(map(str, self.GetCurrentLocation()))
+ self.IEC60870ClientNode.setConfiguration_Name(
+ "IEC60870 Client " + loc_str)
+
+ def GetNodeCount(self):
+ return (1, 0)
+
+ def GetConfigName(self):
+ return self.IEC60870ClientNode.getConfiguration_Name()
+
+ def GetVariableLocationTree(self):
+ current_location = self.GetCurrentLocation()
+ name = self.BaseParams.getName()
+
+ entries = []
+ entries.append({
+ "name": "Connection Status",
+ "type": LOCATION_VAR_MEMORY,
+ "size": 8,
+ "IEC_type": "BYTE",
+ "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)",
+ "children": []})
+ entries.append({
+ "name": "Interrogation Trigger",
+ "type": LOCATION_VAR_MEMORY,
+ "size": 1,
+ "IEC_type": "BOOL",
+ "var_name": "var_name",
+ "location": "X" + ".".join(
+ [str(i) for i in current_location]) + ".1",
+ "description": "Trigger general interrogation",
+ "children": []})
+
+ for child in self.IECSortedChildren():
+ entries.append(child.GetVariableLocationTree())
+
+ return {"name": name,
+ "type": LOCATION_CONFNODE,
+ "location": ".".join(
+ [str(i) for i in current_location]) + ".x",
+ "children": entries}
+
+ def CTNGenerate_C(self, buildpath, locations):
+ return [], "", False
+
+
+#
+# R O O T C L A S S
+#
+
+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:complexType>
+ <xsd:attribute name="MaxRemoteClients" use="optional" default="10">
+ <xsd:simpleType>
+ <xsd:restriction base="xsd:integer">
+ <xsd:minInclusive value="0"/>
+ <xsd:maxInclusive value="65535"/>
+ </xsd:restriction>
+ </xsd:simpleType>
+ </xsd:attribute>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ """
+ CTNChildrenTypes = [
+ ("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():
+ total = tuple(
+ x1 + x2 for x1, x2 in zip(total, child.GetNodeCount()))
+ return total
+
+ def GetIPServerPortNumbers(self):
+ port_numbers = []
+ for child in self.IECSortedChildren():
+ if child.CTNType == "IEC60870Server":
+ port_numbers.extend(child.GetIPServerPortNumbers())
+ return port_numbers
+
+ def GetConfigNames(self):
+ names = []
+ for child in self.IECSortedChildren():
+ names.append(
+ (child.GetCurrentLocation(), child.GetConfigName()))
+ return names
+
+ def CTNGenerate_C(self, buildpath, locations):
+ node_config_names = []
+ 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_message = _(
+ "Error: IEC60870 plugin nodes %%{a1}.x and %%{a2}.x "
+ "use the same Configuration_Name \"{a3}\".\n"
+ ).format(
+ 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)
+
+ ip_ports = []
+ for CTNInstance in self.GetCTRoot().IterChildren():
+ if CTNInstance.CTNType == "iec60870":
+ ip_ports.extend(CTNInstance.GetIPServerPortNumbers())
+
+ i = 0
+ for loc1, addr1, port1 in ip_ports[:-1]:
+ i = i + 1
+ for loc2, addr2, port2 in ip_ports[i:]:
+ if (port1 == port2) and (
+ (addr1 == addr2)
+ or (addr1 in ("", "*", "#ANY#"))
+ or (addr2 in ("", "*", "#ANY#"))
+ ):
+ error_message = _(
+ "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"
+ ).format(
+ a1=_lt_to_str(loc1), a2=_lt_to_str(loc2),
+ a3=port1, a4=addr1, a5=addr2)
+ self.FatalError(error_message)
+
+ return [], "", False