pymscada 0.0.15__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.
- pymscada/checkout.py +38 -5
- pymscada/demo/logixclient.yaml +6 -10
- pymscada/demo/modbusclient.yaml +22 -18
- pymscada/demo/modbusserver.yaml +10 -2
- pymscada/demo/ping.yaml +12 -0
- pymscada/demo/pymscada-io-ping.service +15 -0
- pymscada/demo/snmpclient.yaml +17 -17
- pymscada/demo/tags.yaml +50 -1
- pymscada/demo/wwwserver.yaml +447 -1
- pymscada/iodrivers/logix_client.py +9 -9
- pymscada/iodrivers/logix_map.py +31 -34
- pymscada/iodrivers/modbus_client.py +20 -34
- pymscada/iodrivers/modbus_map.py +4 -1
- pymscada/iodrivers/modbus_server.py +34 -48
- pymscada/iodrivers/ping_client.py +120 -0
- pymscada/iodrivers/ping_map.py +43 -0
- pymscada/iodrivers/snmp_client.py +4 -2
- pymscada/iodrivers/snmp_map.py +1 -1
- pymscada/main.py +239 -71
- pymscada/validate.py +127 -35
- pymscada-0.1.0.dist-info/METADATA +88 -0
- {pymscada-0.0.15.dist-info → pymscada-0.1.0.dist-info}/RECORD +26 -24
- pymscada/demo/simulate.yaml +0 -21
- pymscada/simulate.py +0 -66
- pymscada-0.0.15.dist-info/METADATA +0 -270
- /pymscada/{iodrivers → tools}/snmp_client2.py +0 -0
- {pymscada-0.0.15.dist-info → pymscada-0.1.0.dist-info}/WHEEL +0 -0
- {pymscada-0.0.15.dist-info → pymscada-0.1.0.dist-info}/entry_points.txt +0 -0
- {pymscada-0.0.15.dist-info → pymscada-0.1.0.dist-info}/licenses/LICENSE +0 -0
pymscada/iodrivers/modbus_map.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
"""
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
51
|
+
yield self.buffer[start:end]
|
|
58
52
|
start = end
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
97
|
-
|
|
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",
|
|
100
|
-
data = bytearray(
|
|
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
|
-
|
|
105
|
-
|
|
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",
|
|
108
|
-
data = bytearray(
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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,
|
|
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
|
|
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:
|
pymscada/iodrivers/snmp_map.py
CHANGED
|
@@ -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['
|
|
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] = {}
|
pymscada/main.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Main server entry point."""
|
|
2
2
|
import argparse
|
|
3
3
|
import asyncio
|
|
4
|
+
from importlib.metadata import version
|
|
4
5
|
import logging
|
|
6
|
+
import sys
|
|
5
7
|
from pymscada.bus_server import BusServer
|
|
6
8
|
from pymscada.checkout import checkout
|
|
7
9
|
from pymscada.config import Config
|
|
@@ -11,92 +13,258 @@ from pymscada.history import History
|
|
|
11
13
|
from pymscada.iodrivers.logix_client import LogixClient
|
|
12
14
|
from pymscada.iodrivers.modbus_client import ModbusClient
|
|
13
15
|
from pymscada.iodrivers.modbus_server import ModbusServer
|
|
16
|
+
from pymscada.iodrivers.ping_client import PingClient
|
|
14
17
|
from pymscada.iodrivers.snmp_client import SnmpClient
|
|
15
|
-
from pymscada.simulate import Simulate
|
|
16
18
|
from pymscada.www_server import WwwServer
|
|
17
19
|
from pymscada.validate import validate
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
"""
|
|
22
|
-
parser = argparse.ArgumentParser(
|
|
23
|
-
prog='pymscada',
|
|
24
|
-
description='Connect IO, logic, applications and webpage UI.',
|
|
25
|
-
epilog='Python MobileSCADA.'
|
|
26
|
-
)
|
|
27
|
-
commands = ['bus', 'console', 'wwwserver', 'history', 'files',
|
|
28
|
-
'logixclient',
|
|
29
|
-
'modbusserver', 'modbusclient',
|
|
30
|
-
'snmpclient',
|
|
31
|
-
'simulate', 'checkout',
|
|
32
|
-
'validate']
|
|
33
|
-
parser.add_argument('module', type=str, choices=commands, metavar='action',
|
|
34
|
-
help=f'select one of: {", ".join(commands)}')
|
|
35
|
-
parser.add_argument('--config', metavar='file',
|
|
36
|
-
help='Config file, default is "[module].yaml".')
|
|
37
|
-
parser.add_argument('--tags', metavar='file',
|
|
38
|
-
help='Tags file, default is "tags.yaml".')
|
|
39
|
-
parser.add_argument('--verbose', action='store_true',
|
|
40
|
-
help="Set level to logging.INFO.")
|
|
41
|
-
parser.add_argument('--path', metavar='folder',
|
|
42
|
-
help="Working folder, used for history and validate.")
|
|
43
|
-
parser.add_argument('--overwrite', action='store_true', default=False,
|
|
44
|
-
help='checkout may overwrite files, CARE!')
|
|
45
|
-
return parser.parse_args()
|
|
22
|
+
class Module():
|
|
23
|
+
"""Default Module."""
|
|
46
24
|
|
|
25
|
+
name = None
|
|
26
|
+
help = None
|
|
27
|
+
epilog = None
|
|
28
|
+
module = None
|
|
29
|
+
config = True
|
|
30
|
+
tags = True
|
|
31
|
+
sub = None
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
33
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
34
|
+
"""Add arguments common to all subparsers."""
|
|
35
|
+
self.sub = subparser.add_parser(
|
|
36
|
+
self.name, help=self.help, epilog=self.epilog)
|
|
37
|
+
self.sub.set_defaults(app=self)
|
|
38
|
+
if self.config:
|
|
39
|
+
self.sub.add_argument(
|
|
40
|
+
'--config', metavar='file', default=f'{self.name}.yaml',
|
|
41
|
+
help=f"Config file, default is '{self.name}.yaml'")
|
|
42
|
+
if self.tags:
|
|
43
|
+
self.sub.add_argument(
|
|
44
|
+
'--tags', metavar='file', default='tags.yaml',
|
|
45
|
+
help="Tags file, default is 'tags.yaml'")
|
|
46
|
+
self.sub.add_argument('--verbose', action='store_true',
|
|
47
|
+
help="Set level to logging.INFO")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Bus(Module):
|
|
51
|
+
"""Bus Server."""
|
|
52
|
+
|
|
53
|
+
name = 'bus'
|
|
54
|
+
help = 'run the message bus'
|
|
55
|
+
tags = False
|
|
56
|
+
|
|
57
|
+
def run_once(self, options):
|
|
58
|
+
"""Create the module."""
|
|
58
59
|
config = Config(options.config)
|
|
59
|
-
module = BusServer(**config)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
self.module = BusServer(**config)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _WwwServer(Module):
|
|
64
|
+
"""WWW Server Module."""
|
|
65
|
+
|
|
66
|
+
name = 'wwwserver'
|
|
67
|
+
help = 'serve web pages'
|
|
68
|
+
|
|
69
|
+
def run_once(self, options):
|
|
70
|
+
"""Create the module."""
|
|
63
71
|
config = Config(options.config)
|
|
64
72
|
tag_info = dict(Config(options.tags))
|
|
65
|
-
module = WwwServer(tag_info=tag_info, **config)
|
|
66
|
-
|
|
73
|
+
self.module = WwwServer(tag_info=tag_info, **config)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _History(Module):
|
|
77
|
+
"""History Module."""
|
|
78
|
+
|
|
79
|
+
name = 'history'
|
|
80
|
+
help = 'collect and serve history'
|
|
81
|
+
|
|
82
|
+
def run_once(self, options):
|
|
83
|
+
"""Create the module."""
|
|
67
84
|
config = Config(options.config)
|
|
68
85
|
tag_info = dict(Config(options.tags))
|
|
69
|
-
module = History(tag_info=tag_info, **config)
|
|
70
|
-
|
|
86
|
+
self.module = History(tag_info=tag_info, **config)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Files(Module):
|
|
90
|
+
"""Bus Module."""
|
|
91
|
+
|
|
92
|
+
name = 'files'
|
|
93
|
+
help = 'receive and send files'
|
|
94
|
+
tags = False
|
|
95
|
+
|
|
96
|
+
def run_once(self, options):
|
|
97
|
+
"""Create the module."""
|
|
71
98
|
config = Config(options.config)
|
|
72
|
-
module = Files(**config)
|
|
73
|
-
|
|
99
|
+
self.module = Files(**config)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Console(Module):
|
|
103
|
+
"""Bus Module."""
|
|
104
|
+
|
|
105
|
+
name = 'console'
|
|
106
|
+
help = 'interactive bus console'
|
|
107
|
+
config = False
|
|
108
|
+
tags = False
|
|
109
|
+
|
|
110
|
+
def run_once(self, _options):
|
|
111
|
+
"""Create the module."""
|
|
112
|
+
self.module = Console()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _checkout(Module):
|
|
116
|
+
"""Bus Module."""
|
|
117
|
+
|
|
118
|
+
name = 'checkout'
|
|
119
|
+
help = 'create example config files'
|
|
120
|
+
epilog = """
|
|
121
|
+
To add to systemd `f="pymscada-bus" && cp config/$f.service
|
|
122
|
+
/lib/systemd/system && systemctl enable $f && systemctl start
|
|
123
|
+
$f`"""
|
|
124
|
+
config = False
|
|
125
|
+
tags = False
|
|
126
|
+
|
|
127
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
128
|
+
super().__init__(subparser)
|
|
129
|
+
self.sub.add_argument(
|
|
130
|
+
'--overwrite', action='store_true', default=False,
|
|
131
|
+
help='checkout may overwrite files, CARE!')
|
|
132
|
+
self.sub.add_argument(
|
|
133
|
+
'--diff', action='store_true', default=False,
|
|
134
|
+
help='compare default with existing')
|
|
135
|
+
|
|
136
|
+
def run_once(self, options):
|
|
137
|
+
"""Create the module."""
|
|
138
|
+
checkout(overwrite=options.overwrite, diff=options.diff)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _validate(Module):
|
|
142
|
+
"""Bus Module."""
|
|
143
|
+
|
|
144
|
+
name = 'validate'
|
|
145
|
+
help = 'validate config files'
|
|
146
|
+
config = False
|
|
147
|
+
tags = False
|
|
148
|
+
|
|
149
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
150
|
+
super().__init__(subparser)
|
|
151
|
+
self.sub.add_argument(
|
|
152
|
+
'--path', metavar='file',
|
|
153
|
+
help='default is current working directory')
|
|
154
|
+
|
|
155
|
+
def run_once(self, options):
|
|
156
|
+
"""Create the module."""
|
|
157
|
+
r, e, p = validate(options.path)
|
|
158
|
+
if r:
|
|
159
|
+
print(f'Config files in {p} valid.')
|
|
160
|
+
else:
|
|
161
|
+
print(e)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _ModbusServer(Module):
|
|
165
|
+
"""Bus Module."""
|
|
166
|
+
|
|
167
|
+
name = 'modbusserver'
|
|
168
|
+
help = 'receive modbus messages'
|
|
169
|
+
epilog = """
|
|
170
|
+
Needs `setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/python3.nn` to
|
|
171
|
+
bind to port 502."""
|
|
172
|
+
tags = False
|
|
173
|
+
|
|
174
|
+
def run_once(self, options):
|
|
175
|
+
"""Create the module."""
|
|
74
176
|
config = Config(options.config)
|
|
75
|
-
module =
|
|
76
|
-
|
|
177
|
+
self.module = ModbusServer(**config)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _ModbusClient(Module):
|
|
181
|
+
"""Bus Module."""
|
|
182
|
+
|
|
183
|
+
name = 'modbusclient'
|
|
184
|
+
help = 'poll/write to modbus devices'
|
|
185
|
+
tags = False
|
|
186
|
+
|
|
187
|
+
def run_once(self, options):
|
|
188
|
+
"""Create the module."""
|
|
77
189
|
config = Config(options.config)
|
|
78
|
-
module = ModbusClient(**config)
|
|
79
|
-
|
|
190
|
+
self.module = ModbusClient(**config)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _LogixClient(Module):
|
|
194
|
+
"""Bus Module."""
|
|
195
|
+
|
|
196
|
+
name = 'logixclient'
|
|
197
|
+
help = 'poll/write to logix devices'
|
|
198
|
+
|
|
199
|
+
def run_once(self, options):
|
|
200
|
+
"""Create the module."""
|
|
80
201
|
config = Config(options.config)
|
|
81
|
-
module =
|
|
82
|
-
|
|
202
|
+
self.module = LogixClient(**config)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _PingClient(Module):
|
|
206
|
+
"""Bus Module."""
|
|
207
|
+
|
|
208
|
+
name = 'ping'
|
|
209
|
+
help = 'ping a list of addresses, return time'
|
|
210
|
+
epilog = """
|
|
211
|
+
Needs `setcap CAP_NET_RAW+ep /usr/bin/python3.nn` to open SOCK_RAW
|
|
212
|
+
"""
|
|
213
|
+
tags = False
|
|
214
|
+
|
|
215
|
+
def run_once(self, options):
|
|
216
|
+
"""Create the module."""
|
|
217
|
+
if sys.platform.startswith("win"):
|
|
218
|
+
asyncio.set_event_loop_policy(
|
|
219
|
+
asyncio.WindowsSelectorEventLoopPolicy())
|
|
83
220
|
config = Config(options.config)
|
|
84
|
-
module =
|
|
85
|
-
|
|
221
|
+
self.module = PingClient(**config)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class _SnmpClient(Module):
|
|
225
|
+
"""Bus Module."""
|
|
226
|
+
|
|
227
|
+
name = 'snmpclient'
|
|
228
|
+
help = 'poll snmp oids'
|
|
229
|
+
tags = False
|
|
230
|
+
|
|
231
|
+
def run_once(self, options):
|
|
232
|
+
"""Create the module."""
|
|
86
233
|
config = Config(options.config)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
234
|
+
self.module = SnmpClient(**config)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def args(_version: str):
|
|
238
|
+
"""Read commandline arguments."""
|
|
239
|
+
parser = argparse.ArgumentParser(
|
|
240
|
+
prog='pymscada',
|
|
241
|
+
description='Connect IO, logic, applications, and webpage UI',
|
|
242
|
+
epilog=f'Python Mobile SCADA {_version}'
|
|
243
|
+
)
|
|
244
|
+
s = parser.add_subparsers(title='module')
|
|
245
|
+
_Bus(s)
|
|
246
|
+
_WwwServer(s)
|
|
247
|
+
_History(s)
|
|
248
|
+
_Files(s)
|
|
249
|
+
_Console(s)
|
|
250
|
+
_checkout(s)
|
|
251
|
+
_validate(s)
|
|
252
|
+
_ModbusServer(s)
|
|
253
|
+
_ModbusClient(s)
|
|
254
|
+
_LogixClient(s)
|
|
255
|
+
_SnmpClient(s)
|
|
256
|
+
_PingClient(s)
|
|
257
|
+
return parser.parse_args()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def run():
|
|
261
|
+
"""Run bus and wwwserver."""
|
|
262
|
+
_version = version("pymscada")
|
|
263
|
+
logging.warning(f'pymscada {_version} starting')
|
|
264
|
+
options = args(_version)
|
|
265
|
+
if options.verbose:
|
|
266
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
267
|
+
options.app.run_once(options)
|
|
268
|
+
if options.app.module is not None:
|
|
269
|
+
await options.app.module.start()
|
|
270
|
+
await asyncio.get_event_loop().create_future()
|