├── .gitignore ├── README.md ├── iec104 ├── __init__.py ├── acpi.py ├── asdu.py ├── client.py ├── server.py ├── tests │ └── types │ │ └── cp56time2a.py └── types.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | IEC 60870-5-104 Client/Server 2 | ============================= 3 | 4 | Summary 5 | ------- 6 | Client/Server for the IEC 60870-5-104 protocol implementation using Tornado for its asynchronous communications core. 7 | 8 | Development 9 | ----------- 10 | Currently iec104 is under heavy development and a lot of features are missing. 11 | 12 | 13 | Licence 14 | ------- 15 | Licensed under the Apache License, Version 2.0 (the "License"); 16 | you may not use this work except in compliance with the License. 17 | You may obtain a copy of the License in the LICENSE file, or at: 18 | 19 | http://www.apache.org/licenses/LICENSE-2.0 20 | 21 | Unless required by applicable law or agreed to in writing, software 22 | distributed under the License is distributed on an "AS IS" BASIS, 23 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 24 | See the License for the specific language governing permissions and 25 | limitations under the License. 26 | 27 | 28 | Contact 29 | ------- 30 | Please do not hesitate to contact me if you have comments or questions. 31 | -------------------------------------------------------------------------------- /iec104/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- -------------------------------------------------------------------------------- /iec104/acpi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import struct 3 | 4 | 5 | TESTFR_CON = '\x83\x00\x00\x00' 6 | TESTFR_ACT = '\x43\x00\x00\x00' 7 | 8 | STOPDT_CON = '\x23\x00\x00\x00' 9 | STOPDT_ACT = '\x13\x00\x00\x00' 10 | 11 | STARTDT_CON = '\x0b\x00\x00\x00' 12 | STARTDT_ACT = '\x07\x00\x00\x00' 13 | 14 | 15 | def i_frame(ssn, rsn): 16 | return struct.pack('<1BHH', 0x64, ssn << 1, rsn << 1) 17 | 18 | 19 | def s_frame(rsn): 20 | return struct.pack('<3BH', 0x64, 0x01, 0x00, rsn << 1) 21 | 22 | 23 | def parse_i_frame(data): 24 | ssn, rsn = struct.unpack('<2H', data) 25 | return ssn >> 1, rsn >> 1 26 | 27 | 28 | def parse_s_frame(data): 29 | rsn = struct.unpack_from('<2H', data)[1] 30 | return rsn >> 1 31 | -------------------------------------------------------------------------------- /iec104/asdu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import binascii 4 | 5 | LOG = logging.getLogger() 6 | 7 | 8 | class ASDU(object): 9 | def __init__(self, data): 10 | print "hex: ", binascii.hexlify(data.bytes) 11 | self.type_id = data.read('uint:8') 12 | sq = data.read('bool') # Single or Sequence 13 | sq_count = data.read('uint:7') 14 | self.cot = data.read('uint:8') 15 | 16 | self.asdu = data.read('uint:16') 17 | LOG.debug("Type: {}, COT: {}, ASDU: {}".format(self.type_id, self.cot, self.asdu)) 18 | 19 | self.objs = [] 20 | if not sq: 21 | for i in xrange(sq_count): 22 | obj = InfoObjMeta.types[self.type_id](data) 23 | self.objs.append(obj) 24 | 25 | 26 | class QDS(object): 27 | def __init__(self, data): 28 | 29 | overflow = bool(data & 0x01) 30 | blocked = bool(data & 0x10) 31 | substituted = bool(data & 0x20) 32 | not_topical = bool(data & 0x40) 33 | invalid = bool(data & 0x80) 34 | 35 | 36 | class InfoObjMeta(type): 37 | types = {} 38 | 39 | def __new__(mcs, name, bases, dct): 40 | re = type.__new__(mcs, name, bases, dct) 41 | if 'type_id' in dct: 42 | InfoObjMeta.types[dct['type_id']] = re 43 | return re 44 | 45 | 46 | class InfoObj(object): 47 | __metaclass__ = InfoObjMeta 48 | 49 | def __init__(self, data): 50 | self.ioa = data.read("int:16") 51 | data.read("int:16") 52 | print "IOA: ", self.ioa 53 | 54 | 55 | class SIQ(InfoObj): 56 | def __init__(self, data): 57 | super(SIQ, self).__init__(data) 58 | self.iv = data.read('bool') 59 | self.nt = data.read('bool') 60 | self.sb = data.read('bool') 61 | self.bl = data.read('bool') 62 | data.read('int:3') # reserve 63 | self.spi = data.read('bool') 64 | 65 | 66 | class DIQ(InfoObj): 67 | def __init__(self, data): 68 | super(DIQ, self).__init__(data) 69 | self.iv = data.read('bool') 70 | self.nt = data.read('bool') 71 | self.sb = data.read('bool') 72 | self.bl = data.read('bool') 73 | data.read('int:2') # reserve 74 | self.dpi = data.read('uint:2') 75 | 76 | 77 | class MSpNa1(SIQ): 78 | type_id = 1 79 | name = 'M_SP_NA_1' 80 | description = 'Single-point information without time tag' 81 | 82 | def __init__(self, data): 83 | super(MSpNa1, self).__init__(data) 84 | LOG.debug('Obj: M_SP_NA_1, Value: {}'.format(self.spi)) 85 | 86 | 87 | class MSpTa1(InfoObj): 88 | type_id = 2 89 | name = 'M_SP_TA_1' 90 | description = 'Single-point information with time tag' 91 | 92 | def __init__(self, data): 93 | super(MSpTa1, self).__init__(data) 94 | 95 | 96 | class MDpNa1(DIQ): 97 | type_id = 3 98 | name = 'M_DP_NA_1' 99 | description = 'Double-point information without time tag' 100 | 101 | def __init__(self, data): 102 | super(MDpNa1, self).__init__(data) 103 | LOG.debug('Obj: M_DP_NA_1, Value: {}'.format(self.dpi)) 104 | 105 | 106 | class MDpTa1(InfoObj): 107 | type_id = 4 108 | name = 'M_DP_TA_1' 109 | description = 'Double-point information with time tag' 110 | 111 | 112 | class MStNa1(InfoObj): 113 | type_id = 5 114 | name = 'M_ST_NA_1' 115 | description = 'Step position information' 116 | 117 | 118 | class MStTa1(InfoObj): 119 | type_id = 6 120 | name = 'M_ST_TA_1' 121 | description = 'Step position information with time tag' 122 | 123 | 124 | class MBoNa1(InfoObj): 125 | type_id = 7 126 | name = 'M_BO_NA_1' 127 | description = 'Bitstring of 32 bit' 128 | 129 | 130 | class MBoTa1(InfoObj): 131 | type_id = 8 132 | name = 'M_BO_TA_1' 133 | description = 'Bitstring of 32 bit with time tag' 134 | 135 | 136 | class MMeNa1(InfoObj): 137 | type_id = 9 138 | name = 'M_ME_NA_1' 139 | description = 'Measured value, normalized value' 140 | 141 | def __init__(self, data): 142 | super(MMeNa1, self).__init__(data) 143 | self.nva = data.read('int:8') 144 | self.nva = data.read('int:16') 145 | LOG.debug('Obj: M_ME_NA_1, Value: {}'.format(self.nva)) 146 | 147 | 148 | class MMeTa1(InfoObj): 149 | type_id = 10 150 | name = 'M_ME_TA_1' 151 | description = 'Measured value, normalized value with time tag' 152 | 153 | 154 | class MMeNb1(InfoObj): 155 | type_id = 11 156 | name = 'M_ME_NB_1' 157 | description = 'Measured value, scaled value' 158 | 159 | 160 | class MMeTb1(InfoObj): 161 | type_id = 12 162 | name = 'M_ME_TB_1' 163 | description = 'Measured value, scaled value with time tag' 164 | 165 | 166 | class MMeNc1(InfoObj): 167 | type_id = 13 168 | name = 'M_ME_NC_1' 169 | description = 'Measured value, short floating point number' 170 | length = 5 171 | 172 | def __init__(self, data): 173 | super(MMeNc1, self).__init__(data) 174 | print binascii.hexlify(data.bytes) 175 | 176 | 177 | val = data.read("floatle:32") 178 | 179 | 180 | #qds = QDS(struct.unpack_from('B', data[7:])[0]) 181 | 182 | print "val", val 183 | 184 | 185 | class MMeTc1(InfoObj): 186 | type_id = 14 187 | name = 'M_ME_TC_1' 188 | description = 'Measured value, short floating point number with time tag' 189 | 190 | 191 | class MItNa1(InfoObj): 192 | type_id = 15 193 | name = 'M_IT_NA_1' 194 | description = 'Integrated totals' 195 | 196 | 197 | class MItTa1(InfoObj): 198 | type_id = 16 199 | name = 'M_IT_TA_1' 200 | description = 'Integrated totals with time tag' 201 | 202 | 203 | class MEpTa1(InfoObj): 204 | type_id = 17 205 | name = 'M_EP_TA_1' 206 | description = 'Event of protection equipment with time tag' 207 | 208 | 209 | class MEpTb1(InfoObj): 210 | type_id = 18 211 | name = 'M_EP_TB_1' 212 | description = 'Packed start events of protection equipment with time tag' 213 | 214 | 215 | class MEpTc1(InfoObj): 216 | type_id = 19 217 | name = 'M_EP_TC_1' 218 | description = 'Packed output circuit information of protection equipment with time tag' 219 | 220 | 221 | class MPsNa1(InfoObj): 222 | type_id = 20 223 | name = 'M_PS_NA_1' 224 | description = 'Packed single-point information with status change detection' 225 | 226 | 227 | class MMeNd1(InfoObj): 228 | type_id = 21 229 | name = 'M_ME_ND_1' 230 | description = 'Measured value, normalized value without quality descriptor' 231 | 232 | 233 | class MSpTb1(InfoObj): 234 | type_id = 30 235 | name = 'M_SP_TB_1' 236 | description = 'Single-point information with time tag CP56Time2a' 237 | 238 | 239 | class MDpTb1(InfoObj): 240 | type_id = 31 241 | name = 'M_DP_TB_1' 242 | description = 'Double-point information with time tag CP56Time2a' 243 | 244 | 245 | class MStTb1(InfoObj): 246 | type_id = 32 247 | name = 'M_ST_TB_1' 248 | description = 'Step position information with time tag CP56Time2a' 249 | 250 | 251 | class MBoTb1(InfoObj): 252 | type_id = 33 253 | name = 'M_BO_TB_1' 254 | description = 'Bitstring of 32 bits with time tag CP56Time2a' 255 | 256 | 257 | class MMeTd1(InfoObj): 258 | type_id = 34 259 | name = 'M_ME_TD_1' 260 | description = 'Measured value, normalized value with time tag CP56Time2a' 261 | 262 | 263 | class MMeTe1(InfoObj): 264 | type_id = 35 265 | name = 'M_ME_TE_1' 266 | description = 'Measured value, scaled value with time tag CP56Time2a' 267 | 268 | 269 | class MMeTf1(InfoObj): 270 | type_id = 36 271 | name = 'M_ME_TF_1' 272 | description = 'Measured value, short floating point number with time tag CP56Time2a' 273 | 274 | 275 | class MItTb1(InfoObj): 276 | type_id = 37 277 | name = 'M_IT_TB_1' 278 | description = 'Integrated totals with time tag CP56Time2a' 279 | 280 | 281 | class MEpTd1(InfoObj): 282 | type_id = 38 283 | name = 'M_EP_TD_1' 284 | description = 'Event of protection equipment with time tag CP56Time2a' 285 | 286 | 287 | class MEpTe1(InfoObj): 288 | type_id = 39 289 | name = 'M_EP_TE_1' 290 | description = 'Packed start events of protection equipment with time tag CP56Time2a' 291 | 292 | 293 | class MEpTf1(InfoObj): 294 | type_id = 40 295 | name = 'M_EP_TF_1' 296 | description = 'Packed output circuit information of protection equipment with time tag CP56Time2a' 297 | 298 | 299 | class CScNa1(InfoObj): 300 | type_id = 45 301 | name = 'C_SC_NA_1' 302 | description = 'Single command' 303 | 304 | 305 | class CDcNa1(InfoObj): 306 | type_id = 46 307 | name = 'C_DC_NA_1' 308 | description = 'Double command' 309 | 310 | 311 | class CRcNa1(InfoObj): 312 | type_id = 47 313 | name = 'C_RC_NA_1' 314 | description = 'Regulating step command' 315 | 316 | 317 | class CSeNa1(InfoObj): 318 | type_id = 48 319 | name = 'C_SE_NA_1' 320 | description = 'Set-point command, normalized value' 321 | 322 | 323 | class CSeNb1(InfoObj): 324 | type_id = 49 325 | name = 'C_SE_NB_1' 326 | description = 'Set-point command, scaled value' 327 | 328 | 329 | class CSeNc1(InfoObj): 330 | type_id = 50 331 | name = 'C_SE_NC_1' 332 | description = 'Set-point command, short floating point number' 333 | 334 | 335 | class CBoNa1(InfoObj): 336 | type_id = 51 337 | name = 'C_BO_NA_1' 338 | description = 'Bitstring of 32 bit' 339 | 340 | 341 | class MEiNa1(InfoObj): 342 | type_id = 70 343 | name = 'M_EI_NA_1' 344 | description = 'End of initialization' 345 | 346 | 347 | class CIcNa1(InfoObj): 348 | type_id = 100 349 | name = 'C_IC_NA_1' 350 | description = 'Interrogation command' 351 | 352 | 353 | class CCiNa1(InfoObj): 354 | type_id = 101 355 | name = 'C_CI_NA_1' 356 | description = 'Counter interrogation command' 357 | 358 | 359 | class CRdNa1(InfoObj): 360 | type_id = 102 361 | name = 'C_RD_NA_1' 362 | description = 'Read command' 363 | 364 | 365 | class CCsNa1(InfoObj): 366 | type_id = 103 367 | name = 'C_CS_NA_1' 368 | description = 'Clock synchronization command' 369 | 370 | 371 | class CTsNa1(InfoObj): 372 | type_id = 104 373 | name = 'C_TS_NA_1' 374 | description = 'Test command' 375 | 376 | 377 | class CRpNa1(InfoObj): 378 | type_id = 105 379 | name = 'C_RP_NA_1' 380 | description = 'Reset process command' 381 | 382 | 383 | class CCdNa1(InfoObj): 384 | type_id = 106 385 | name = 'C_CD_NA_1' 386 | descripiton = 'Delay acquisition command' 387 | 388 | 389 | class PMeNa1(InfoObj): 390 | type_id = 110 391 | name = 'P_ME_NA_1' 392 | description = 'Parameter of measured values, normalized value' 393 | 394 | 395 | class PMeNb1(InfoObj): 396 | type_id = 111 397 | name = 'P_ME_NB_1' 398 | description = 'Parameter of measured values, scaled value' 399 | 400 | 401 | class PMeNc1(InfoObj): 402 | type_id = 112 403 | name = 'P_ME_NC_1' 404 | description = 'Parameter of measured values, short floating point number' 405 | 406 | 407 | class PAcNa1(InfoObj): 408 | type_id = 113 409 | name = 'P_AC_NA_1' 410 | description = 'Parameter activation' 411 | 412 | 413 | class FFrNa1(InfoObj): 414 | type_id = 120 415 | name = 'F_FR_NA_1' 416 | description = 'File ready' 417 | 418 | 419 | class FSrNa1(InfoObj): 420 | type_id = 121 421 | name = 'F_SR_NA_1' 422 | description = 'Section ready' 423 | 424 | 425 | class FScNa1(InfoObj): 426 | type_id = 122 427 | name = 'F_SC_NA_1' 428 | description = 'Call directory, select file, call file, call section' 429 | 430 | 431 | class FLsNa1(InfoObj): 432 | type_id = 123 433 | name = 'F_LS_NA_1' 434 | description = 'Last section, last segment' 435 | 436 | 437 | class FAdNa1(InfoObj): 438 | type_id = 124 439 | name = 'F_AF_NA_1' 440 | description = 'ACK file, ACK section' 441 | 442 | 443 | class FSgNa1(InfoObj): 444 | type_id = 125 445 | name = 'F_SG_NA_1' 446 | description = 'Segment' 447 | 448 | 449 | class FDrTa1(InfoObj): 450 | type_id = 126 451 | name = 'F_DR_TA_1' 452 | description = 'Directory' -------------------------------------------------------------------------------- /iec104/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import tornado.ioloop 3 | import tornado.iostream 4 | import socket 5 | import binascii 6 | import acpi 7 | import asdu 8 | import struct 9 | import logging 10 | from bitstring import ConstBitStream 11 | from tornado.gen import Task, engine 12 | 13 | LOG = logging.getLogger() 14 | logging.basicConfig(level=logging.DEBUG) 15 | 16 | 17 | class IEC104Client(object): 18 | 19 | def __init__(self): 20 | self.socket = socket.socket() 21 | self.ssn = 0 22 | self.rsn = 0 23 | self.stream = tornado.iostream.IOStream(self.socket) 24 | 25 | def connect(self, ip, port=2404): 26 | self.stream.connect((ip, port), self.connect_callback) 27 | 28 | @engine 29 | def receive(self, data): 30 | start, length = struct.unpack('2B', data) 31 | print "len:", length 32 | data = yield Task(self.stream.read_bytes, length) 33 | s_acpi = ''.join(struct.unpack_from('4s', data)) # keep 0x00 34 | acpi_control = struct.unpack_from('B', data)[0] 35 | 36 | if acpi_control & 1 == 0: # I-FRAME 37 | ssn, rsn = acpi.parse_i_frame(s_acpi) 38 | LOG.debug("ssn: {}, rsn: {}".format(ssn, rsn)) 39 | s_asdu = ConstBitStream(bytes=data, offset=4*8) 40 | o_asdu = asdu.ASDU(s_asdu) 41 | 42 | elif acpi_control & 3 == 1: # S-FRAME 43 | print "B" 44 | rsn = acpi.parse_s_frame(s_acpi) 45 | print rsn 46 | 47 | elif acpi_control & 3 == 3: # U-FRAME 48 | print "A" 49 | if s_acpi == acpi.STARTDT_CON: 50 | print 'connected' 51 | 52 | if s_acpi == acpi.TESTFR_ACT: 53 | print 'ping' 54 | yield Task(self.send, acpi.TESTFR_CON) 55 | self.stream.read_bytes(2, self.receive) 56 | 57 | @engine 58 | def connect_callback(self): 59 | print "connect" 60 | LOG.debug("Send STARTDT_ACT") 61 | yield Task(self.send, acpi.STARTDT_ACT) 62 | self.stream.read_bytes(2, self.receive) 63 | 64 | def send(self, data, callback): 65 | self.stream.write("\x68" + struct.pack("B", len(data)) + data, callback) 66 | 67 | 68 | if __name__ == "__main__": 69 | iec = IEC104Client() 70 | iec.connect('127.0.0.1') 71 | tornado.ioloop.IOLoop.instance().start() -------------------------------------------------------------------------------- /iec104/server.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ebolon/iec104/fa7905247869c7a8934dd6a003cb5091b5b1211c/iec104/server.py -------------------------------------------------------------------------------- /iec104/tests/types/cp56time2a.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def test_cp56time2a(): 5 | pass 6 | -------------------------------------------------------------------------------- /iec104/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | 5 | def cp56timebcd(buf): 6 | pass 7 | 8 | 9 | def cp56time2a_to_time(buf): 10 | microsecond = (buf[1] & 0xFF) << 8 | (buf[0] & 0xFF) 11 | microsecond %= 1000 12 | second = int(microsecond) 13 | minute = buf[2] & 0x3F 14 | hour = buf[3] & 0x1F 15 | day = buf[4] & 0x1F 16 | month = (buf[5] & 0x0F) - 1 17 | year = (buf[6] & 0x7F) + 2000 18 | 19 | return datetime(year, month, day, minute, hour, second, microsecond) 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | setup( 5 | name="iec104", 6 | version="0.0.1", 7 | install_requires=['tornado'], 8 | packages=find_packages(), 9 | ) --------------------------------------------------------------------------------