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.
- pymscada/__init__.py +7 -3
- pymscada/demo/logixclient.yaml +50 -0
- pymscada/demo/modbus_plc.py +46 -29
- pymscada/demo/modbusclient.yaml +10 -1
- pymscada/demo/modbusserver.yaml +12 -3
- pymscada/demo/pymscada-bus.service +1 -1
- pymscada/demo/pymscada-files.service +1 -1
- pymscada/demo/pymscada-history.service +1 -1
- pymscada/demo/pymscada-io-logixclient.service +15 -0
- pymscada/demo/{pymscada-modbusclient.service → pymscada-io-modbusclient.service} +1 -1
- pymscada/demo/{pymscada-modbusserver.service → pymscada-io-modbusserver.service} +1 -1
- pymscada/demo/pymscada-io-snmpclient.service +15 -0
- pymscada/demo/pymscada-wwwserver.service +1 -1
- pymscada/demo/snmpclient.yaml +73 -0
- pymscada/demo/tags.yaml +110 -5
- pymscada/demo/wwwserver.yaml +16 -10
- pymscada/history.py +46 -97
- pymscada/iodrivers/__init__.py +0 -0
- pymscada/iodrivers/logix_client.py +80 -0
- pymscada/iodrivers/logix_map.py +147 -0
- pymscada/{modbus_client.py → iodrivers/modbus_client.py} +3 -3
- pymscada/{modbus_server.py → iodrivers/modbus_server.py} +1 -1
- pymscada/iodrivers/snmp_client.py +75 -0
- pymscada/iodrivers/snmp_client2.py +53 -0
- pymscada/iodrivers/snmp_map.py +71 -0
- pymscada/main.py +24 -4
- pymscada/periodic.py +1 -1
- pymscada/samplers.py +93 -0
- pymscada/tag.py +0 -27
- pymscada/tools/walk.py +55 -0
- pymscada/validate.py +356 -0
- pymscada-0.0.15.dist-info/METADATA +270 -0
- pymscada-0.0.15.dist-info/RECORD +58 -0
- {pymscada-0.0.6.dist-info → pymscada-0.0.15.dist-info}/WHEEL +1 -1
- pymscada-0.0.6.dist-info/METADATA +0 -58
- pymscada-0.0.6.dist-info/RECORD +0 -45
- /pymscada/{modbus_map.py → iodrivers/modbus_map.py} +0 -0
- {pymscada-0.0.6.dist-info → pymscada-0.0.15.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
while len(
|
|
100
|
-
if
|
|
101
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 +=
|
|
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,
|
|
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(
|
|
84
|
+
self.process(buffer[start:end])
|
|
85
85
|
start = end
|
|
86
86
|
|
|
87
87
|
|
|
@@ -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)
|