pymscada 0.0.14__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pymscada might be problematic. Click here for more details.

@@ -1,6 +1,5 @@
1
1
  """Modbus Client."""
2
2
  import asyncio
3
- from itertools import chain
4
3
  import logging
5
4
  from struct import pack, unpack_from
6
5
  from pymscada.bus_client import BusClient
@@ -43,53 +42,40 @@ class ModbusClientProtocol(asyncio.Protocol):
43
42
  transport.set_write_buffer_limits(high=0)
44
43
  self.transport = transport
45
44
 
46
- def data_received(self, recv):
47
- """Received TCP data, see if there is a full modbus packet."""
48
- # logging.info("data_received")
49
- # logging.info(f'tcp echo server received: {recv}')
45
+ def unpack_mb(self):
46
+ """Return complete modbus packets and trim the buffer."""
50
47
  start = 0
51
- self.buffer += recv
52
48
  while True:
53
49
  buf_len = len(self.buffer)
54
- if buf_len < 6 + start: # assumes possible mbap_len of 0
55
- self.buffer = self.buffer[start:]
50
+ if buf_len < 6 + start: # enough to unpack length
56
51
  break
57
- (_mbap_tr, _mbap_pr, mbap_len) = unpack_from(
58
- ">3H", self.buffer, start
59
- )
60
- if buf_len < 6 + mbap_len:
61
- self.buffer = self.buffer[start:]
52
+ mbap_tr, mbap_pr, mbap_len = unpack_from(">3H", self.buffer, start)
53
+ if buf_len < start + 6 + mbap_len: # there is a complete message
62
54
  break
63
55
  end = start + 6 + mbap_len
64
- # got a complete message, set start to end for buffer prune
65
- self.process(self.buffer[start:end])
56
+ yield self.buffer[start:end]
66
57
  start = end
58
+ self.buffer = self.buffer[end:]
59
+
60
+ def data_received(self, recv):
61
+ """Received TCP data, see if there is a full modbus packet."""
62
+ self.buffer += recv
63
+ for msg in self.unpack_mb():
64
+ self.process(msg)
67
65
 
68
66
  def datagram_received(self, recv, _addr):
69
67
  """Received a UDP packet, discard any partial packets."""
70
68
  # logging.info("datagram_received")
71
- start = 0
72
- buffer = recv
73
- while True:
74
- buf_len = len(buffer)
75
- if buf_len < 6 + start: # assumes possible mbap_len of 0
76
- buffer = buffer[start:]
77
- break
78
- (_mbap_tr, _mbap_pr, mbap_len) = unpack_from(">3H", buffer, start)
79
- if buf_len < 6 + mbap_len:
80
- buffer = buffer[start:]
81
- break
82
- end = start + 6 + mbap_len
83
- # got a complete message, set start to end for buffer prune
84
- self.process(buffer[start:end])
85
- start = end
69
+ self.buffer = recv
70
+ for msg in self.unpack_mb():
71
+ self.process(msg)
86
72
 
87
73
 
88
74
  class ModbusClientConnector:
89
75
  """Poll Modbus device, write on change in write range."""
90
76
 
91
77
  def __init__(self, name: str, ip: str, port: int, rate: int, tcp_udp: str,
92
- read: list, writeok: list, mapping: ModbusMaps):
78
+ poll: list, mapping: ModbusMaps):
93
79
  """
94
80
  Set up polling client.
95
81
 
@@ -101,13 +87,13 @@ class ModbusClientConnector:
101
87
  self.tcp_udp = tcp_udp
102
88
  self.transport = None
103
89
  self.protocol = None
104
- self.read = read
105
- self.writeok = writeok
90
+ self.read = poll
91
+ self.writeok = None
106
92
  self.periodic = Periodic(self.poll, rate)
107
93
  self.mapping = mapping
108
94
  self.sent = {}
109
95
  tables = {}
110
- for file_range in chain(read, writeok):
96
+ for file_range in poll: # chain(read, writeok):
111
97
  unit = file_range['unit']
112
98
  file = file_range['file']
113
99
  table = f'{name}:{unit}:{file}'
@@ -139,7 +139,10 @@ class ModbusMaps():
139
139
  """Make the maps."""
140
140
  for tagname, v in self.tags.items():
141
141
  dtype = v['type']
142
- addr = v['addr']
142
+ try:
143
+ addr = v['read']
144
+ except KeyError:
145
+ addr = v['addr']
143
146
  map = ModbusMap(tagname, dtype, addr, self.data, self.value_chg)
144
147
  size = DTYPES[dtype][3]
145
148
  name, unit, file, word = addr.split(':')
@@ -37,80 +37,65 @@ class ModbusServerProtocol:
37
37
  transport.set_write_buffer_limits(high=0)
38
38
  self.transport = transport
39
39
 
40
- def data_received(self, recv):
41
- """Received."""
42
- # logging.info(f'received: {recv}')
40
+ def unpack_mb(self):
41
+ """Return complete modbus packets and trim the buffer."""
43
42
  start = 0
44
- self.buffer += recv
45
43
  while True:
46
44
  buf_len = len(self.buffer)
47
45
  if buf_len < 6 + start: # enough to unpack length
48
- self.buffer = self.buffer[start:]
49
46
  break
50
- (_mbap_tr, _mbap_pr, mbap_len) = unpack_from(
51
- ">3H", self.buffer, start
52
- )
53
- if buf_len < 6 + mbap_len: # there is a complete message
54
- self.buffer = self.buffer[start:]
47
+ mbap_tr, mbap_pr, mbap_len = unpack_from(">3H", self.buffer, start)
48
+ if buf_len < start + 6 + mbap_len: # there is a complete message
55
49
  break
56
50
  end = start + 6 + mbap_len
57
- self.msg = self.buffer[start:end]
51
+ yield self.buffer[start:end]
58
52
  start = end
59
- self.process()
60
- if self.msg is not None:
61
- self.transport.write(self.msg)
62
- self.msg = b""
53
+ self.buffer = self.buffer[end:]
54
+
55
+ def data_received(self, recv):
56
+ """Received."""
57
+ # logging.info(f'received: {recv}')
58
+ self.buffer += recv
59
+ for msg in self.unpack_mb():
60
+ reply = self.process(msg)
61
+ self.transport.write(reply)
63
62
 
64
63
  def datagram_received(self, recv, addr):
65
64
  """Received."""
66
- start = 0
67
- buffer = recv
68
- while True:
69
- buf_len = len(buffer)
70
- if buf_len < 6 + start: # enough to unpack length
71
- buffer = buffer[start:]
72
- break
73
- (_mbap_tr, _mbap_pr, mbap_len) = unpack_from(">3H", buffer, start)
74
- if buf_len < 6 + mbap_len: # there is a complete message
75
- buffer = buffer[start:]
76
- break
77
- end = start + 6 + mbap_len
78
- self.msg = buffer[start:end]
79
- start = end
80
- self.process()
81
- if self.msg is not None:
82
- self.transport.sendto(self.msg, addr)
83
- self.msg = b""
65
+ self.buffer = recv
66
+ for msg in self.unpack_mb():
67
+ reply = self.process(msg)
68
+ self.transport.sendto(reply, addr)
84
69
 
85
- def process(self):
70
+ def process(self, msg):
86
71
  """Process."""
87
72
  mbap_tr, mbap_pr, _mbap_len, mbap_unit, pdu_fc = unpack_from(
88
- ">3H2B", self.msg, 0)
73
+ ">3H2B", msg, 0)
89
74
  if pdu_fc == 3: # Read Holding Registers
90
75
  # Return 0 for missing addresses
91
- pdu_start, pdu_count = unpack_from(">2H", self.msg, 8)
76
+ pdu_start, pdu_count = unpack_from(">2H", msg, 8)
92
77
  data = self.mapping.get_data(self.name, mbap_unit, '4x', pdu_start,
93
78
  pdu_count)
94
79
  data_len = len(data)
95
80
  msg_len = 3 + data_len
96
- self.msg = pack('>3H3B', mbap_tr, mbap_pr, msg_len, mbap_unit,
97
- pdu_fc, data_len) + data
81
+ reply = pack('>3H3B', mbap_tr, mbap_pr, msg_len, mbap_unit,
82
+ pdu_fc, data_len) + data
98
83
  elif pdu_fc == 6: # Set Single Register 4x
99
- pdu_start = unpack_from(">H", self.msg, 8)[0]
100
- data = bytearray(self.msg[10:12])
84
+ pdu_start = unpack_from(">H", msg, 8)[0]
85
+ data = bytearray(msg[10:12])
101
86
  self.mapping.set_data(self.name, mbap_unit, '4x', pdu_start,
102
87
  1, data)
103
88
  msg_len = 6
104
- self.msg = pack(">3H2BH", mbap_tr, mbap_pr, msg_len, mbap_unit,
105
- pdu_fc, pdu_start) + data
89
+ reply = pack(">3H2BH", mbap_tr, mbap_pr, msg_len, mbap_unit,
90
+ pdu_fc, pdu_start) + data
106
91
  elif pdu_fc == 16: # Set Multiple Registers 4x
107
- pdu_start, pdu_count, pdu_bytes = unpack_from(">2HB", self.msg, 8)
108
- data = bytearray(self.msg[13:13 + pdu_bytes])
92
+ pdu_start, pdu_count, pdu_bytes = unpack_from(">2HB", msg, 8)
93
+ data = bytearray(msg[13:13 + pdu_bytes])
109
94
  self.mapping.set_data(self.name, mbap_unit, '4x', pdu_start,
110
95
  pdu_count, data)
111
96
  msg_len = 6
112
- self.msg = pack(">3H2B2H", mbap_tr, mbap_pr, msg_len, mbap_unit,
113
- pdu_fc, pdu_start, pdu_count)
97
+ reply = pack(">3H2B2H", mbap_tr, mbap_pr, msg_len, mbap_unit,
98
+ pdu_fc, pdu_start, pdu_count)
114
99
  else:
115
100
  # Unsupported, send the standard Modbus exception
116
101
  logging.warn(
@@ -118,8 +103,9 @@ class ModbusServerProtocol:
118
103
  f" attempted FC {pdu_fc}"
119
104
  )
120
105
  msg_len = 3
121
- self.msg = pack(">3H2BB", mbap_tr, mbap_pr, msg_len, mbap_unit,
122
- pdu_fc + 128, 1)
106
+ reply = pack(">3H2BB", mbap_tr, mbap_pr, msg_len, mbap_unit,
107
+ pdu_fc + 128, 1)
108
+ return reply
123
109
 
124
110
 
125
111
  class ModbusServerConnector:
@@ -0,0 +1,120 @@
1
+ """Network monitoring / scanning."""
2
+ import asyncio
3
+ import logging
4
+ import socket
5
+ import struct
6
+ import time
7
+ from pymscada.bus_client import BusClient
8
+ from pymscada.periodic import Periodic
9
+ from pymscada.iodrivers.ping_map import PingMaps
10
+
11
+
12
+ ICMP_REQUEST = 8
13
+ ICMP_REPLY = 0
14
+ RATE = 60
15
+
16
+
17
+ def checksum_chat(data):
18
+ """Calculate ICMP Checksum."""
19
+ if len(data) & 1: # If the packet length is odd
20
+ data += b'\0'
21
+ res = 0
22
+ # Process two bytes at a time
23
+ for i in range(0, len(data), 2):
24
+ res += (data[i] << 8) + data[i + 1]
25
+ res = (res >> 16) + (res & 0xffff)
26
+ res += res >> 16
27
+ return (~res) & 0xffff # Return ones' complement of the result
28
+
29
+
30
+ class PingClientConnector:
31
+ """Ping a list of addresses."""
32
+
33
+ def __init__(self, mapping: PingMaps):
34
+ """Accept list of addresses, ip or name."""
35
+ self.mapping = mapping
36
+ self.dns = {}
37
+ self.addr_info = {}
38
+ self.socket = None
39
+ self.ping_id = 0
40
+ self.ping_dict = {}
41
+
42
+ async def poll(self):
43
+ """Do pings."""
44
+ self.reply_dict = {}
45
+ if self.socket is None:
46
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_RAW,
47
+ socket.IPPROTO_ICMP)
48
+ asyncio.get_event_loop().add_reader(self.socket,
49
+ self.read_response)
50
+ for ping_id, address in list(self.ping_dict.items()):
51
+ logging.info(f'failed {self.dns[address]} {ping_id}')
52
+ self.mapping.polled_data(self.dns[address], float('NAN'))
53
+ del self.ping_dict[ping_id]
54
+ self.send_ping()
55
+
56
+ def send_ping(self):
57
+ """Build and send ping messages."""
58
+ for address in self.dns.keys():
59
+ self.ping_id = (self.ping_id + 1) & 0xffff
60
+ self.ping_dict[self.ping_id] = address
61
+ logging.info(f'ping {address} id {self.ping_id}')
62
+ header = struct.pack("!BbHHh", ICMP_REQUEST, 0, 0, self.ping_id, 1)
63
+ data = struct.pack("!d", time.perf_counter()) + (60 * b'\0')
64
+ checksum = checksum_chat(header + data)
65
+ packet = struct.pack("!BbHHh", ICMP_REQUEST, 0, checksum,
66
+ self.ping_id, 1) + data
67
+ self.socket.sendto(packet, (address, 0))
68
+
69
+ def read_response(self):
70
+ """Match ping response."""
71
+ data, address = self.socket.recvfrom(1024)
72
+ msgtype, _, _, ping_id, _ = struct.unpack('!BBHHH', data[20:28])
73
+ if msgtype != ICMP_REPLY:
74
+ return
75
+ if ping_id in self.ping_dict:
76
+ latency = 1000 * (time.perf_counter() -
77
+ struct.unpack('!d', data[28:36])[0])
78
+ name = self.dns[address[0]]
79
+ logging.info(f'success {name} {ping_id} {latency}ms')
80
+ self.mapping.polled_data(name, latency)
81
+ del self.ping_dict[ping_id]
82
+
83
+ async def start(self):
84
+ """Start pinging."""
85
+ loop = asyncio.get_event_loop()
86
+ for address in self.mapping.var_map.keys():
87
+ info = await loop.getaddrinfo(address, None, family=socket.AF_INET,
88
+ type=socket.SOCK_STREAM)
89
+ ip = info[0][4][0]
90
+ self.dns[ip] = address
91
+ self.periodic = Periodic(self.poll, RATE)
92
+ await self.periodic.start()
93
+
94
+
95
+ class PingClient:
96
+ """Ping client."""
97
+
98
+ def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
99
+ tags: dict = {}) -> None:
100
+ """
101
+ Connect to bus on bus_ip:bus_port, ping a list.
102
+
103
+ Event loop must be running.
104
+ """
105
+ self.busclient = None
106
+ if bus_ip is not None:
107
+ self.busclient = BusClient(bus_ip, bus_port)
108
+ self.mapping = PingMaps(tags)
109
+ self.pinger = PingClientConnector(mapping=self.mapping)
110
+
111
+ async def _poll(self):
112
+ """For testing."""
113
+ for connection in self.connections:
114
+ await connection.poll()
115
+
116
+ async def start(self):
117
+ """Start bus connection and PLC polling."""
118
+ if self.busclient is not None:
119
+ await self.busclient.start()
120
+ await self.pinger.start()
@@ -0,0 +1,43 @@
1
+ """Map between snmp MIB and Tag."""
2
+ from time import time
3
+ from pymscada.tag import Tag
4
+
5
+
6
+ class PingMap:
7
+ """Do value updates for each tag."""
8
+
9
+ def __init__(self, tagname: str, addr: str):
10
+ """Initialise MIB map and Tag."""
11
+ self.last_value = None
12
+ self.tag = Tag(tagname, float)
13
+ self.addr = addr
14
+ self.map_bus = id(self)
15
+
16
+ def set_tag_value(self, value, time_us):
17
+ """Pass update from IO driver to tag value."""
18
+ if self.last_value is None:
19
+ self.last_value = value
20
+ return
21
+ if self.last_value != value:
22
+ self.tag.value = value, time_us, self.map_bus
23
+
24
+
25
+ class PingMaps:
26
+ """Link tags with protocol connector."""
27
+
28
+ def __init__(self, tags: dict):
29
+ """Collect maps based on a tag dictionary."""
30
+ # use the tagname to access the map.
31
+ self.tag_map: dict[str, PingMap] = {}
32
+ # use the plc_name then variable name to access a list of maps.
33
+ self.var_map: dict[str, PingMap] = {}
34
+ for tagname, v in tags.items():
35
+ addr = v['addr']
36
+ map = PingMap(tagname, addr)
37
+ self.var_map[addr] = map
38
+ self.tag_map[tagname] = map
39
+
40
+ def polled_data(self, address, latency):
41
+ """Pass updates read from the PLC to the tags."""
42
+ time_us = int(time() * 1e6)
43
+ self.var_map[address].set_tag_value(latency, time_us)
@@ -1,3 +1,4 @@
1
+ """Poll SNMP OIDs from devices."""
1
2
  import logging
2
3
  import pysnmp.hlapi.asyncio as snmp
3
4
  from pymscada.bus_client import BusClient
@@ -8,14 +9,14 @@ from pymscada.iodrivers.snmp_map import SnmpMaps
8
9
  class SnmpClientConnector:
9
10
  """Poll snmp devices, write and traps are not implemented."""
10
11
 
11
- def __init__(self, name: str, ip:str, rate: float, read: list,
12
+ def __init__(self, name: str, ip: str, rate: float, poll: list,
12
13
  community: str, mapping: SnmpMaps):
13
14
  """Set up polling client."""
14
15
  self.snmp_name = name
15
16
  self.ip = ip
16
17
  self.community = community
17
18
  self.read_oids = [snmp.ObjectType(snmp.ObjectIdentity(x))
18
- for x in read]
19
+ for x in poll]
19
20
  self.mapping = mapping
20
21
  self.periodic = Periodic(self.poll, rate)
21
22
  self.snmp_engine = snmp.SnmpEngine()
@@ -45,6 +46,7 @@ class SnmpClientConnector:
45
46
 
46
47
 
47
48
  class SnmpClient:
49
+ """SNMP client."""
48
50
 
49
51
  def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
50
52
  rtus: dict = {}, tags: dict = {}) -> None:
@@ -52,7 +52,7 @@ class SnmpMaps:
52
52
  # use the plc_name then variable name to access a list of maps.
53
53
  self.var_map: dict[str, dict[str, list[SnmpMap]]] = {}
54
54
  for tagname, v in tags.items():
55
- addr = v['addr']
55
+ addr = v['read']
56
56
  map = SnmpMap(tagname, v['type'], addr)
57
57
  if map.plc not in self.var_map:
58
58
  self.var_map[map.plc] = {}