pymscada 0.0.6__py3-none-any.whl → 0.0.15__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.

Files changed (39) hide show
  1. pymscada/__init__.py +7 -3
  2. pymscada/demo/logixclient.yaml +50 -0
  3. pymscada/demo/modbus_plc.py +46 -29
  4. pymscada/demo/modbusclient.yaml +10 -1
  5. pymscada/demo/modbusserver.yaml +12 -3
  6. pymscada/demo/pymscada-bus.service +1 -1
  7. pymscada/demo/pymscada-files.service +1 -1
  8. pymscada/demo/pymscada-history.service +1 -1
  9. pymscada/demo/pymscada-io-logixclient.service +15 -0
  10. pymscada/demo/{pymscada-modbusclient.service → pymscada-io-modbusclient.service} +1 -1
  11. pymscada/demo/{pymscada-modbusserver.service → pymscada-io-modbusserver.service} +1 -1
  12. pymscada/demo/pymscada-io-snmpclient.service +15 -0
  13. pymscada/demo/pymscada-wwwserver.service +1 -1
  14. pymscada/demo/snmpclient.yaml +73 -0
  15. pymscada/demo/tags.yaml +110 -5
  16. pymscada/demo/wwwserver.yaml +16 -10
  17. pymscada/history.py +46 -97
  18. pymscada/iodrivers/__init__.py +0 -0
  19. pymscada/iodrivers/logix_client.py +80 -0
  20. pymscada/iodrivers/logix_map.py +147 -0
  21. pymscada/{modbus_client.py → iodrivers/modbus_client.py} +3 -3
  22. pymscada/{modbus_server.py → iodrivers/modbus_server.py} +1 -1
  23. pymscada/iodrivers/snmp_client.py +75 -0
  24. pymscada/iodrivers/snmp_client2.py +53 -0
  25. pymscada/iodrivers/snmp_map.py +71 -0
  26. pymscada/main.py +24 -4
  27. pymscada/periodic.py +1 -1
  28. pymscada/samplers.py +93 -0
  29. pymscada/tag.py +0 -27
  30. pymscada/tools/walk.py +55 -0
  31. pymscada/validate.py +356 -0
  32. pymscada-0.0.15.dist-info/METADATA +270 -0
  33. pymscada-0.0.15.dist-info/RECORD +58 -0
  34. {pymscada-0.0.6.dist-info → pymscada-0.0.15.dist-info}/WHEEL +1 -1
  35. pymscada-0.0.6.dist-info/METADATA +0 -58
  36. pymscada-0.0.6.dist-info/RECORD +0 -45
  37. /pymscada/{modbus_map.py → iodrivers/modbus_map.py} +0 -0
  38. {pymscada-0.0.6.dist-info → pymscada-0.0.15.dist-info}/entry_points.txt +0 -0
  39. {pymscada-0.0.6.dist-info → pymscada-0.0.15.dist-info}/licenses/LICENSE +0 -0
pymscada/history.py CHANGED
@@ -19,7 +19,7 @@ def tag_for_history(tagname: str, tag: dict):
19
19
  tag['name'] = tagname
20
20
  tag['id'] = None
21
21
  if 'desc' not in tag:
22
- tag['desc'] = tag.name
22
+ tag['desc'] = tag['name']
23
23
  if 'multi' in tag:
24
24
  tag['type'] = int
25
25
  else:
@@ -38,6 +38,18 @@ def tag_for_history(tagname: str, tag: dict):
38
38
  tag['deadband'] = None
39
39
 
40
40
 
41
+ def get_tag_hist_files(path: Path, tagname: str) -> dict[int, Path]:
42
+ """Parse path for history files matching tagname."""
43
+ files_us = {}
44
+ for file in path.glob(f'{tagname}_*.dat'):
45
+ parts = file.stem.split('_')
46
+ parts_tag = '_'.join(parts[:-1])
47
+ if parts_tag != tagname or not parts[-1].isdigit():
48
+ continue
49
+ files_us[int(parts[-1])] = file
50
+ return files_us
51
+
52
+
41
53
  class TagHistory():
42
54
  """Efficiently store and serve history for a given tagname."""
43
55
 
@@ -87,109 +99,46 @@ class TagHistory():
87
99
  self.chunks: int = 0
88
100
  self.file: Path = None
89
101
 
90
- def read(self, start_us: int = 0, end_us: int = -1):
102
+ def read_bytes(self, start_us: int = 0, end_us: int = -1):
91
103
  """Read in partial store on start-up, or read-in older data."""
92
- resp_time = []
93
- resp_values = []
94
- files_us: dict[int, Path] = {}
95
- for file in self.path.glob(f'{self.name}_*.dat'):
96
- i = file.stem.rindex('_') + 1
97
- files_us[int(file.stem[i:])] = file
98
- times = [x for x in sorted(files_us.keys())]
99
- while len(times) > 2:
100
- if times[0] < start_us and times[1] < start_us:
101
- times.pop(0)
104
+ resp: bytes = b''
105
+ # find the chunks that cover the time span requested
106
+ srcs = get_tag_hist_files(self.path, self.name)
107
+ if self.chunk_idx > 0:
108
+ time_us, _ = unpack_from(self.packstr, self.chunk)
109
+ srcs[time_us] = None
110
+ times_us = [x for x in sorted(srcs.keys())]
111
+ while len(times_us) > 1:
112
+ if times_us[1] <= start_us:
113
+ times_us.pop(0)
102
114
  else:
103
115
  break
104
116
  if end_us != -1:
105
- while times[-1] > end_us:
106
- times.pop()
107
- for time_us in times:
108
- size = files_us[time_us].stat().st_size
109
- if size % ITEM_SIZE != 0:
110
- logging.warning(f'{files_us[time_us]} size is incorrect.')
111
- size -= size % ITEM_SIZE
112
- with open(files_us[time_us], 'rb') as fh:
113
- dat = fh.read()
114
- for i in range(0, size, ITEM_SIZE):
115
- vtime_us, value = unpack_from(self.packstr, dat, offset=i)
116
- if end_us != -1 and vtime_us >= end_us:
117
- break
118
- if vtime_us >= start_us:
119
- resp_time.append(vtime_us)
120
- resp_values.append(value)
121
- if end_us != -1 and vtime_us >= end_us:
117
+ while len(times_us) > 1:
118
+ if times_us[-1] > end_us:
119
+ times_us.pop()
120
+ else:
122
121
  break
123
- for i in range(0, self.chunk_idx, ITEM_SIZE):
124
- vtime_us, value = unpack_from(self.packstr, self.chunk, offset=i)
125
- if end_us != -1 and vtime_us >= end_us:
126
- break
127
- if vtime_us >= start_us:
128
- resp_time.append(vtime_us)
129
- resp_values.append(value)
130
- return resp_time, resp_values
131
-
132
- def read_bytes(self, start_us: int = 0, end_us: int = -1):
133
- """Read in partial store on start-up, or read-in older data."""
134
- resp: bytes = b''
135
- files_us: dict[int, Path] = {}
136
- for file in self.path.glob(f'{self.name}_*.dat'):
137
- i = file.stem.rindex('_') + 1
138
- files_us[int(file.stem[i:])] = file
139
- times = [x for x in sorted(files_us.keys())]
140
- while len(times) > 1:
141
- if times[0] < start_us and times[1] < start_us:
142
- times.pop(0)
143
- elif end_us != -1 and times[-1] > end_us:
144
- times.pop()
122
+ # collect the chunks into a single response
123
+ for time_us in times_us:
124
+ if srcs[time_us] is None:
125
+ dat = self.chunk
126
+ end = self.chunk_idx
145
127
  else:
146
- break
147
- idx = 0
148
- dat = b''
149
- for idx, time_us in enumerate(times):
150
- start = 0
151
- end = files_us[time_us].stat().st_size
152
- if end % ITEM_SIZE != 0:
153
- logging.warning(f'{files_us[time_us]} size is incorrect.')
154
- end -= end % ITEM_SIZE
155
- with open(files_us[time_us], 'rb') as fh:
156
- dat = fh.read()
157
- if idx == 0: # find start
158
- for i in range(0, end, ITEM_SIZE):
159
- vtime_us, value = unpack_from(self.packstr, dat,
160
- offset=i)
161
- if vtime_us >= start_us:
162
- start = i
163
- break
164
- if end_us != -1 and idx == len(times) - 1: # find end
165
- for i in range(end - ITEM_SIZE, 0, -ITEM_SIZE):
166
- vtime_us, value = unpack_from(self.packstr, dat,
167
- offset=i)
168
- if vtime_us < end_us:
169
- end = i + ITEM_SIZE
170
- break
171
- resp += dat[start:end]
172
- add_chunk = False
173
- if self.chunk_idx > 0:
174
- vtime_us, value = unpack_from(self.packstr, self.chunk, offset=0)
175
- if vtime_us < end_us:
176
- add_chunk = True
177
- if end_us == -1 or add_chunk:
178
- start = 0
179
- end = self.chunk_idx
180
- if idx == 0: # find start
181
- for i in range(0, self.chunk_idx, ITEM_SIZE):
182
- vtime_us, value = unpack_from(self.packstr, dat, offset=i)
183
- if vtime_us >= start_us:
184
- start = i
185
- break
186
- for i in range(self.chunk_idx - ITEM_SIZE, 0, -ITEM_SIZE):
187
- vtime_us, value = unpack_from(self.packstr, self.chunk,
188
- offset=i)
189
- if vtime_us < end_us:
190
- end = i + ITEM_SIZE
128
+ with open(srcs[time_us], 'rb') as fh:
129
+ dat = fh.read()
130
+ end = len(dat)
131
+ for start in range(0, end, ITEM_SIZE):
132
+ first_us, _ = unpack_from(self.packstr, dat, offset=start)
133
+ if first_us >= start_us:
134
+ break
135
+ for end in range(end - ITEM_SIZE, start - ITEM_SIZE, -ITEM_SIZE):
136
+ last_us, _ = unpack_from(self.packstr, dat,
137
+ offset=end)
138
+ if last_us < end_us or end_us == -1:
139
+ end += ITEM_SIZE
191
140
  break
192
- resp += self.chunk[start:end]
141
+ resp += dat[start:end]
193
142
  return resp
194
143
 
195
144
  def flush(self):
File without changes
@@ -0,0 +1,80 @@
1
+ """Read and write to Logix PLC processor."""
2
+ import logging
3
+ from pycomm3 import LogixDriver
4
+ from pymscada.bus_client import BusClient
5
+ from pymscada.periodic import Periodic
6
+ from pymscada.iodrivers.logix_map import LogixMaps
7
+
8
+
9
+ class LogixClientConnector:
10
+ """Poll Logix device, write on change in write range."""
11
+
12
+ def __init__(self, name: str, ip:str, rate: float, read: list,
13
+ writeok: list, mapping: LogixMaps):
14
+ """Set up polling client."""
15
+ self.plc_name = name
16
+ self.ip = ip
17
+ self.read_tags = []
18
+ for r in read:
19
+ tag = r['addr']
20
+ if r['type'].endswith('[]'):
21
+ count = r['end'] - r['start'] + 1
22
+ tag = f"{r['addr']}[{r['start']}]{{{count}}}"
23
+ self.read_tags.append(tag)
24
+ self.mapping = mapping
25
+ self.mapping.add_write_callback(name, writeok,
26
+ self.write_tag_update)
27
+ self.periodic = Periodic(self.poll, rate)
28
+ self.plc = LogixDriver(ip)
29
+
30
+ def write_tag_update(self, addr: str, value): # : int|float
31
+ """Write out any tag updates."""
32
+ if not self.plc.connected and not self.plc.open():
33
+ logging.warning(f'write failed {self.plc_name} {addr} to {value}')
34
+ return
35
+ logging.info(f'writing {addr} {value}')
36
+ res = self.plc.write((addr, value))
37
+ pass
38
+
39
+ async def poll(self):
40
+ """Poll data, reopen connection if dead."""
41
+ if not self.plc.connected and not self.plc.open():
42
+ return
43
+ polled_tags = None
44
+ polled_tags = self.plc.read(*self.read_tags)
45
+ self.mapping.polled_data(self.plc_name, polled_tags)
46
+
47
+ async def start(self):
48
+ """Start polling."""
49
+ await self.periodic.start()
50
+
51
+
52
+ class LogixClient:
53
+
54
+ def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
55
+ rtus: dict = {}, tags: dict = {}) -> None:
56
+ """
57
+ Connect to bus on bus_ip:bus_port, connect to Logix PLCs.
58
+
59
+ Event loop must be running.
60
+ """
61
+ self.busclient = None
62
+ if bus_ip is not None:
63
+ self.busclient = BusClient(bus_ip, bus_port)
64
+ self.mapping = LogixMaps(tags)
65
+ self.connections: list[LogixClientConnector] = []
66
+ for rtu in rtus:
67
+ connection = LogixClientConnector(**rtu, mapping=self.mapping)
68
+ self.connections.append(connection)
69
+
70
+ async def _poll(self):
71
+ """For testing."""
72
+ for connection in self.connections:
73
+ await connection.poll()
74
+
75
+ async def start(self):
76
+ """Start bus connection and PLC polling."""
77
+ if self.busclient is not None:
78
+ await self.busclient.start()
79
+ for connection in self.connections:
80
+ await connection.start()
@@ -0,0 +1,147 @@
1
+ """Map between modbus table and Tag."""
2
+ import logging
3
+ from time import time
4
+ from pymscada.tag import Tag
5
+
6
+
7
+ # data types for PLCs
8
+ DTYPES = {
9
+ 'int32': [int, -2147483648, 2147483647],
10
+ 'float32': [float, -3.40282346639e+38, 3.40282346639e+38],
11
+ 'bool': [int, 0, 1]
12
+ }
13
+
14
+
15
+ def tag_split(plc_tag: str):
16
+ separator = plc_tag.find(':')
17
+ arr_start_loc = plc_tag.find('[')
18
+ arr_end_loc = plc_tag.find(']')
19
+ bit_loc = plc_tag.find('.')
20
+ plc = plc_tag[:separator]
21
+ if arr_start_loc == -1 and bit_loc == -1:
22
+ var = plc_tag[separator + 1:]
23
+ elm = None
24
+ bit = None
25
+ elif arr_start_loc == -1:
26
+ var = plc_tag[separator + 1:bit_loc]
27
+ elm = None
28
+ bit = int(plc_tag[bit_loc + 1:])
29
+ elif bit_loc == -1:
30
+ var = plc_tag[separator + 1:arr_start_loc]
31
+ elm = int(plc_tag[arr_start_loc + 1:arr_end_loc])
32
+ bit = None
33
+ else:
34
+ var = plc_tag[separator + 1:arr_start_loc]
35
+ elm = int(plc_tag[arr_start_loc + 1:arr_end_loc])
36
+ bit = int(plc_tag[bit_loc + 1:])
37
+ return plc, var, elm, bit
38
+
39
+
40
+ class LogixMap:
41
+ """Do value updates for each tag."""
42
+
43
+ def __init__(self, tagname: str, src_type: str, read_tag: str,
44
+ write_tag: str):
45
+ """Initialise modbus map and Tag."""
46
+ dtype, dmin, dmax = DTYPES[src_type][0:3]
47
+ self.tag = Tag(tagname, dtype)
48
+ self.map_bus = id(self)
49
+ self.read_plc, self.read_var, self.read_elm, self.read_bit = \
50
+ tag_split(read_tag)
51
+ self.plc_read_tag = read_tag
52
+ self.write_plc, self.write_var, self.write_elm, self.write_bit = \
53
+ tag_split(write_tag)
54
+ self.plc_write_tag = write_tag
55
+ self.callback = None
56
+ if dmin is not None:
57
+ self.tag.value_min = dmin
58
+ if dmax is not None:
59
+ self.tag.value_max = dmax
60
+
61
+ def set_callback(self, callback):
62
+ """Add tag callback interface."""
63
+ self.callback = callback
64
+ self.tag.add_callback(self.tag_value_changed, bus_id=self.map_bus)
65
+
66
+ def set_tag_value(self, value, time_us):
67
+ """Pass update from IO driver to tag value."""
68
+ if self.read_bit is not None:
69
+ if value & 1 << self.read_bit:
70
+ value = 1
71
+ else:
72
+ value = 0
73
+ if self.tag.value != value:
74
+ self.tag.value = value, time_us, self.map_bus
75
+
76
+ def tag_value_changed(self, tag: Tag):
77
+ """Pass update from tag value to IO driver."""
78
+ if self.write_elm is None and self.write_bit is None:
79
+ addr = self.write_var
80
+ elif self.write_elm is None:
81
+ addr = f'{self.write_var}.{self.write_bit}'
82
+ elif self.write_bit is None:
83
+ addr = f'{self.write_var}[{self.write_elm}]'
84
+ else:
85
+ addr = f'{self.write_var}[{self.write_elm}].{self.write_bit}'
86
+ self.callback(addr, tag.value)
87
+
88
+
89
+ class LogixMaps:
90
+ """Link tags with protocol connector."""
91
+
92
+ def __init__(self, tags: dict):
93
+ """Collect maps based on a tag dictionary."""
94
+ # use the tagname to access the map.
95
+ self.tag_map: dict[str, LogixMap] = {}
96
+ # use the plc_name then variable name to access a list of maps.
97
+ self.read_var_map: dict[str, dict[str, list[LogixMap]]] = {}
98
+ for tagname, v in tags.items():
99
+ if 'addr' in v:
100
+ read_addr = v['addr']
101
+ write_addr = v['addr']
102
+ else:
103
+ read_addr = v['read']
104
+ write_addr = v['write']
105
+ map = LogixMap(tagname, v['type'], read_addr, write_addr)
106
+ if map.read_plc not in self.read_var_map:
107
+ self.read_var_map[map.read_plc] = {}
108
+ if map.read_var not in self.read_var_map[map.read_plc]:
109
+ # make a list so multiple bits can map to a word
110
+ self.read_var_map[map.read_plc][map.read_var] = []
111
+ self.read_var_map[map.read_plc][map.read_var].append(map)
112
+ self.tag_map[map.tag.name] = map
113
+
114
+ def add_write_callback(self, plcname, writeok, callback):
115
+ """Connection advises device links."""
116
+ # Create a set of all possible valid addresses
117
+ write_set = set()
118
+ for w in writeok:
119
+ if '[' in w['type']:
120
+ for i in range(w['start'], w['end'] + 1):
121
+ write_set.add((w['addr'], i))
122
+ else:
123
+ write_set.add((w['addr'], None))
124
+ # where the mapped tag uses a valid address, add callback to
125
+ # the connection writer
126
+ for map in self.tag_map.values():
127
+ if map.write_plc == plcname and \
128
+ (map.write_var, map.write_elm) in write_set:
129
+ map.set_callback(callback)
130
+
131
+ def polled_data(self, plcname, polls):
132
+ """Pass updates read from the PLC to the tags."""
133
+ time_us = int(time() * 1e6)
134
+ for poll in polls:
135
+ if poll.error is not None:
136
+ logging.error(poll.error)
137
+ arr_start_loc = poll.tag.find('[')
138
+ if arr_start_loc == -1:
139
+ for map in self.read_var_map[plcname][poll.tag]:
140
+ map.set_tag_value(poll.value, time_us)
141
+ else:
142
+ var = poll.tag[:arr_start_loc]
143
+ elm = int(poll.tag[arr_start_loc + 1: -1])
144
+ for map in self.read_var_map[plcname][var]:
145
+ elm_offset = map.read_elm - elm
146
+ if elm_offset > 0 and elm_offset < len(poll.value):
147
+ map.set_tag_value(poll.value[elm_offset], time_us)
@@ -4,7 +4,7 @@ from itertools import chain
4
4
  import logging
5
5
  from struct import pack, unpack_from
6
6
  from pymscada.bus_client import BusClient
7
- from pymscada.modbus_map import ModbusMaps
7
+ from pymscada.iodrivers.modbus_map import ModbusMaps
8
8
  from pymscada.periodic import Periodic
9
9
 
10
10
 
@@ -66,7 +66,7 @@ class ModbusClientProtocol(asyncio.Protocol):
66
66
  start = end
67
67
 
68
68
  def datagram_received(self, recv, _addr):
69
- """Received a UDP packet, see if it is a full modbus packet."""
69
+ """Received a UDP packet, discard any partial packets."""
70
70
  # logging.info("datagram_received")
71
71
  start = 0
72
72
  buffer = recv
@@ -81,7 +81,7 @@ class ModbusClientProtocol(asyncio.Protocol):
81
81
  break
82
82
  end = start + 6 + mbap_len
83
83
  # got a complete message, set start to end for buffer prune
84
- self.process(self.buffer[start:end])
84
+ self.process(buffer[start:end])
85
85
  start = end
86
86
 
87
87
 
@@ -3,7 +3,7 @@ import asyncio
3
3
  import logging
4
4
  from struct import pack, unpack_from
5
5
  from pymscada.bus_client import BusClient
6
- from pymscada.modbus_map import ModbusMaps
6
+ from pymscada.iodrivers.modbus_map import ModbusMaps
7
7
 
8
8
 
9
9
  class ModbusServerProtocol:
@@ -0,0 +1,75 @@
1
+ import logging
2
+ import pysnmp.hlapi.asyncio as snmp
3
+ from pymscada.bus_client import BusClient
4
+ from pymscada.periodic import Periodic
5
+ from pymscada.iodrivers.snmp_map import SnmpMaps
6
+
7
+
8
+ class SnmpClientConnector:
9
+ """Poll snmp devices, write and traps are not implemented."""
10
+
11
+ def __init__(self, name: str, ip:str, rate: float, read: list,
12
+ community: str, mapping: SnmpMaps):
13
+ """Set up polling client."""
14
+ self.snmp_name = name
15
+ self.ip = ip
16
+ self.community = community
17
+ self.read_oids = [snmp.ObjectType(snmp.ObjectIdentity(x))
18
+ for x in read]
19
+ self.mapping = mapping
20
+ self.periodic = Periodic(self.poll, rate)
21
+ self.snmp_engine = snmp.SnmpEngine()
22
+
23
+ async def poll(self):
24
+ """Poll data."""
25
+ r = await snmp.getCmd(
26
+ self.snmp_engine,
27
+ snmp.CommunityData(self.community),
28
+ snmp.UdpTransportTarget((self.ip, 161)),
29
+ snmp.ContextData(),
30
+ *self.read_oids
31
+ )
32
+ errorIndication, errorStatus, errorIndex, varBinds = r
33
+ if errorIndication:
34
+ logging.error(errorIndication)
35
+ elif errorStatus:
36
+ logging.error('%s at %s' % (
37
+ errorStatus.prettyPrint(),
38
+ errorIndex and varBinds[int(errorIndex) - 1][0] or '?'))
39
+ else:
40
+ self.mapping.polled_data(self.snmp_name, varBinds)
41
+
42
+ async def start(self):
43
+ """Start polling."""
44
+ await self.periodic.start()
45
+
46
+
47
+ class SnmpClient:
48
+
49
+ def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
50
+ rtus: dict = {}, tags: dict = {}) -> None:
51
+ """
52
+ Connect to bus on bus_ip:bus_port, connect to snmp devices.
53
+
54
+ Event loop must be running.
55
+ """
56
+ self.busclient = None
57
+ if bus_ip is not None:
58
+ self.busclient = BusClient(bus_ip, bus_port)
59
+ self.mapping = SnmpMaps(tags)
60
+ self.connections: list[SnmpClientConnector] = []
61
+ for rtu in rtus:
62
+ connection = SnmpClientConnector(**rtu, mapping=self.mapping)
63
+ self.connections.append(connection)
64
+
65
+ async def _poll(self):
66
+ """For testing."""
67
+ for connection in self.connections:
68
+ await connection.poll()
69
+
70
+ async def start(self):
71
+ """Start bus connection and PLC polling."""
72
+ if self.busclient is not None:
73
+ await self.busclient.start()
74
+ for connection in self.connections:
75
+ await connection.start()
@@ -0,0 +1,53 @@
1
+ import asyncio
2
+ from pysnmp.hlapi.asyncio import *
3
+
4
+
5
+ async def run():
6
+ base = [
7
+ '1.3.6.1.2.1.2.2.1.2.', # name
8
+ # '.1.3.6.1.2.1.2.2.1.4.', # mtu
9
+ # '.1.3.6.1.2.1.2.2.1.6.', # mac address
10
+ # '.1.3.6.1.2.1.2.2.1.7.', # admin status
11
+ # '.1.3.6.1.2.1.2.2.1.8.', # oper status
12
+ '1.3.6.1.2.1.31.1.1.1.6.', # bytes in
13
+ # '.1.3.6.1.2.1.31.1.1.1.7.', # packets in
14
+ # '.1.3.6.1.2.1.2.2.1.13.', # discards in
15
+ # '.1.3.6.1.2.1.2.2.1.14.', # errors in
16
+ '1.3.6.1.2.1.31.1.1.1.10.', # bytes out
17
+ # '.1.3.6.1.2.1.31.1.1.1.11.', # packets out
18
+ # '.1.3.6.1.2.1.2.2.1.19.', # discards out
19
+ # '.1.3.6.1.2.1.2.2.1.20.', # errors out
20
+ ]
21
+ oids = []
22
+ for i in range(1, 9):
23
+ for b in base:
24
+ oids.append(ObjectType(ObjectIdentity(f'{b}{i}')))
25
+ ip_address = '172.26.3.254'
26
+ community = 'public'
27
+
28
+ snmp_engine = SnmpEngine()
29
+
30
+ r = await getCmd(
31
+ snmp_engine,
32
+ CommunityData(community),
33
+ UdpTransportTarget((ip_address, 161)),
34
+ ContextData(),
35
+ *oids
36
+ )
37
+ errorIndication, errorStatus, errorIndex, varBinds = r
38
+ if errorIndication:
39
+ print(errorIndication)
40
+ elif errorStatus:
41
+ print('%s at %s' % (
42
+ errorStatus.prettyPrint(),
43
+ errorIndex and varBinds[int(errorIndex) - 1][0] or '?'))
44
+ else:
45
+ for varBind in varBinds:
46
+ oid, value = varBind
47
+ print(str(oid), type(oid), str(value), type(value))
48
+ # snmp_engine.transportDispatcher.closeDispatcher()
49
+
50
+
51
+ if __name__ == '__main__':
52
+ """Starts with creating an event loop."""
53
+ asyncio.run(run())
@@ -0,0 +1,71 @@
1
+ """Map between snmp MIB and Tag."""
2
+ import logging
3
+ from time import time
4
+ from pymscada.tag import Tag
5
+
6
+
7
+ # data types for MIBs
8
+ DTYPES = {
9
+ 'int_roc': [int]
10
+ }
11
+
12
+
13
+ class SnmpMap:
14
+ """Do value updates for each tag."""
15
+
16
+ def __init__(self, tagname: str, src_type: str, plc_tag: str):
17
+ """initialise MIB map and Tag."""
18
+ dtype = DTYPES[src_type][0]
19
+ self.last_value = None
20
+ self.tag = Tag(tagname, dtype)
21
+ self.map_bus = id(self)
22
+ separator = plc_tag.find(':')
23
+ self.plc = plc_tag[:separator]
24
+ self.var = plc_tag[separator + 1:]
25
+
26
+ def set_tag_value(self, value, time_us):
27
+ """Pass update from IO driver to tag value."""
28
+ vtype = type(value).__name__
29
+ if vtype == 'Counter64':
30
+ v = int(value)
31
+ if self.last_value is None:
32
+ self.last_value = v
33
+ return
34
+ d = v - self.last_value
35
+ self.last_value = v
36
+ if d < 0:
37
+ d += 2**64
38
+ if self.tag.value != d:
39
+ self.tag.value = d, time_us, self.map_bus
40
+ else:
41
+ logging.warning(
42
+ f'SnmpMap: {self.tag.name} {vtype} not implemented')
43
+
44
+
45
+ class SnmpMaps:
46
+ """Link tags with protocol connector."""
47
+
48
+ def __init__(self, tags: dict):
49
+ """Collect maps based on a tag dictionary."""
50
+ # use the tagname to access the map.
51
+ self.tag_map: dict[str, SnmpMap] = {}
52
+ # use the plc_name then variable name to access a list of maps.
53
+ self.var_map: dict[str, dict[str, list[SnmpMap]]] = {}
54
+ for tagname, v in tags.items():
55
+ addr = v['addr']
56
+ map = SnmpMap(tagname, v['type'], addr)
57
+ if map.plc not in self.var_map:
58
+ self.var_map[map.plc] = {}
59
+ if map.var not in self.var_map[map.plc]:
60
+ # make a list so multiple bits can map to a word
61
+ self.var_map[map.plc][map.var] = []
62
+ self.var_map[map.plc][map.var].append(map)
63
+ self.tag_map[map.tag.name] = map
64
+
65
+ def polled_data(self, plcname, polls):
66
+ """Pass updates read from the PLC to the tags."""
67
+ time_us = int(time() * 1e6)
68
+ for poll in polls:
69
+ oid, value = poll
70
+ for map in self.var_map[plcname][str(oid)]:
71
+ map.set_tag_value(value, time_us)