pymscada 0.1.0a5__py3-none-any.whl → 0.1.1a2__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/bus_client.py +15 -11
- pymscada/bus_server.py +16 -10
- pymscada/console.py +213 -16
- pymscada/demo/files.yaml +1 -0
- pymscada/demo/opnotes.yaml +5 -0
- pymscada/demo/pymscada-opnotes.service +16 -0
- pymscada/demo/wwwserver.yaml +2 -5
- pymscada/files.py +47 -28
- pymscada/history.py +18 -17
- pymscada/iodrivers/logix_client.py +1 -1
- pymscada/iodrivers/modbus_client.py +2 -1
- pymscada/iodrivers/modbus_map.py +4 -1
- pymscada/iodrivers/modbus_server.py +2 -1
- pymscada/iodrivers/ping_client.py +1 -1
- pymscada/iodrivers/snmp_client.py +3 -3
- pymscada/iodrivers/snmp_map.py +1 -1
- pymscada/main.py +233 -115
- pymscada/opnotes.py +108 -0
- pymscada/protocol_constants.py +11 -8
- pymscada/tag.py +22 -0
- pymscada/www_server.py +47 -52
- {pymscada-0.1.0a5.dist-info → pymscada-0.1.1a2.dist-info}/METADATA +2 -2
- {pymscada-0.1.0a5.dist-info → pymscada-0.1.1a2.dist-info}/RECORD +26 -23
- {pymscada-0.1.0a5.dist-info → pymscada-0.1.1a2.dist-info}/WHEEL +1 -1
- {pymscada-0.1.0a5.dist-info → pymscada-0.1.1a2.dist-info}/entry_points.txt +0 -0
- {pymscada-0.1.0a5.dist-info → pymscada-0.1.1a2.dist-info}/licenses/LICENSE +0 -0
|
@@ -161,7 +161,8 @@ class ModbusServer:
|
|
|
161
161
|
"""
|
|
162
162
|
self.busclient = None
|
|
163
163
|
if bus_ip is not None:
|
|
164
|
-
self.busclient = BusClient(bus_ip, bus_port
|
|
164
|
+
self.busclient = BusClient(bus_ip, bus_port,
|
|
165
|
+
module='Modbus Server')
|
|
165
166
|
self.mapping = ModbusMaps(tags)
|
|
166
167
|
self.connections: list[ModbusServerConnector] = []
|
|
167
168
|
for rtu in rtus:
|
|
@@ -104,7 +104,7 @@ class PingClient:
|
|
|
104
104
|
"""
|
|
105
105
|
self.busclient = None
|
|
106
106
|
if bus_ip is not None:
|
|
107
|
-
self.busclient = BusClient(bus_ip, bus_port)
|
|
107
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Ping Client')
|
|
108
108
|
self.mapping = PingMaps(tags)
|
|
109
109
|
self.pinger = PingClientConnector(mapping=self.mapping)
|
|
110
110
|
|
|
@@ -9,14 +9,14 @@ from pymscada.iodrivers.snmp_map import SnmpMaps
|
|
|
9
9
|
class SnmpClientConnector:
|
|
10
10
|
"""Poll snmp devices, write and traps are not implemented."""
|
|
11
11
|
|
|
12
|
-
def __init__(self, name: str, ip: str, rate: float,
|
|
12
|
+
def __init__(self, name: str, ip: str, rate: float, poll: list,
|
|
13
13
|
community: str, mapping: SnmpMaps):
|
|
14
14
|
"""Set up polling client."""
|
|
15
15
|
self.snmp_name = name
|
|
16
16
|
self.ip = ip
|
|
17
17
|
self.community = community
|
|
18
18
|
self.read_oids = [snmp.ObjectType(snmp.ObjectIdentity(x))
|
|
19
|
-
for x in
|
|
19
|
+
for x in poll]
|
|
20
20
|
self.mapping = mapping
|
|
21
21
|
self.periodic = Periodic(self.poll, rate)
|
|
22
22
|
self.snmp_engine = snmp.SnmpEngine()
|
|
@@ -57,7 +57,7 @@ class SnmpClient:
|
|
|
57
57
|
"""
|
|
58
58
|
self.busclient = None
|
|
59
59
|
if bus_ip is not None:
|
|
60
|
-
self.busclient = BusClient(bus_ip, bus_port)
|
|
60
|
+
self.busclient = BusClient(bus_ip, bus_port, module='SNMP Client')
|
|
61
61
|
self.mapping = SnmpMaps(tags)
|
|
62
62
|
self.connections: list[SnmpClientConnector] = []
|
|
63
63
|
for rtu in rtus:
|
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
|
@@ -10,110 +10,253 @@ from pymscada.config import Config
|
|
|
10
10
|
from pymscada.console import Console
|
|
11
11
|
from pymscada.files import Files
|
|
12
12
|
from pymscada.history import History
|
|
13
|
+
from pymscada.opnotes import OpNotes
|
|
14
|
+
from pymscada.www_server import WwwServer
|
|
13
15
|
from pymscada.iodrivers.logix_client import LogixClient
|
|
14
16
|
from pymscada.iodrivers.modbus_client import ModbusClient
|
|
15
17
|
from pymscada.iodrivers.modbus_server import ModbusServer
|
|
16
18
|
from pymscada.iodrivers.ping_client import PingClient
|
|
17
19
|
from pymscada.iodrivers.snmp_client import SnmpClient
|
|
18
|
-
from pymscada.www_server import WwwServer
|
|
19
20
|
from pymscada.validate import validate
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
class Module():
|
|
24
|
+
"""Default Module."""
|
|
25
|
+
|
|
26
|
+
name = None
|
|
27
|
+
help = None
|
|
28
|
+
epilog = None
|
|
29
|
+
module = None
|
|
30
|
+
config = True
|
|
31
|
+
tags = True
|
|
32
|
+
sub = None
|
|
33
|
+
await_future = True
|
|
34
|
+
|
|
35
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
36
|
+
"""Add arguments common to all subparsers."""
|
|
37
|
+
self.sub = subparser.add_parser(
|
|
38
|
+
self.name, help=self.help, epilog=self.epilog)
|
|
39
|
+
self.sub.set_defaults(app=self)
|
|
40
|
+
if self.config:
|
|
41
|
+
self.sub.add_argument(
|
|
42
|
+
'--config', metavar='file', default=f'{self.name}.yaml',
|
|
43
|
+
help=f"Config file, default is '{self.name}.yaml'")
|
|
44
|
+
if self.tags:
|
|
45
|
+
self.sub.add_argument(
|
|
46
|
+
'--tags', metavar='file', default='tags.yaml',
|
|
47
|
+
help="Tags file, default is 'tags.yaml'")
|
|
48
|
+
self.sub.add_argument('--verbose', action='store_true',
|
|
49
|
+
help="Set level to logging.INFO")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Bus(Module):
|
|
53
|
+
"""Bus Server."""
|
|
54
|
+
|
|
55
|
+
name = 'bus'
|
|
56
|
+
help = 'run the message bus'
|
|
57
|
+
tags = False
|
|
58
|
+
|
|
59
|
+
def run_once(self, options):
|
|
60
|
+
"""Create the module."""
|
|
61
|
+
config = Config(options.config)
|
|
62
|
+
self.module = BusServer(**config)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _WwwServer(Module):
|
|
66
|
+
"""WWW Server Module."""
|
|
67
|
+
|
|
68
|
+
name = 'wwwserver'
|
|
69
|
+
help = 'serve web pages'
|
|
70
|
+
|
|
71
|
+
def run_once(self, options):
|
|
72
|
+
"""Create the module."""
|
|
73
|
+
config = Config(options.config)
|
|
74
|
+
tag_info = dict(Config(options.tags))
|
|
75
|
+
self.module = WwwServer(tag_info=tag_info, **config)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _History(Module):
|
|
79
|
+
"""History Module."""
|
|
80
|
+
|
|
81
|
+
name = 'history'
|
|
82
|
+
help = 'collect and serve history'
|
|
83
|
+
|
|
84
|
+
def run_once(self, options):
|
|
85
|
+
"""Create the module."""
|
|
86
|
+
config = Config(options.config)
|
|
87
|
+
tag_info = dict(Config(options.tags))
|
|
88
|
+
self.module = History(tag_info=tag_info, **config)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _Files(Module):
|
|
92
|
+
"""Files Module."""
|
|
93
|
+
|
|
94
|
+
name = 'files'
|
|
95
|
+
help = 'receive and send files'
|
|
96
|
+
tags = False
|
|
97
|
+
|
|
98
|
+
def run_once(self, options):
|
|
99
|
+
"""Create the module."""
|
|
100
|
+
config = Config(options.config)
|
|
101
|
+
self.module = Files(**config)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _OpNotes(Module):
|
|
105
|
+
"""Operator Notes Module."""
|
|
106
|
+
|
|
107
|
+
name = 'opnotes'
|
|
108
|
+
help = 'present and manage operator notes'
|
|
109
|
+
tags = False
|
|
110
|
+
|
|
111
|
+
def run_once(self, options):
|
|
112
|
+
"""Create the module."""
|
|
113
|
+
config = Config(options.config)
|
|
114
|
+
self.module = OpNotes(**config)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Console(Module):
|
|
118
|
+
"""Bus Module."""
|
|
119
|
+
|
|
120
|
+
name = 'console'
|
|
121
|
+
help = 'interactive bus console'
|
|
122
|
+
config = False
|
|
123
|
+
await_future = False
|
|
124
|
+
|
|
125
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
126
|
+
super().__init__(subparser)
|
|
127
|
+
self.sub.add_argument(
|
|
128
|
+
'-p', '--port', action='store', type=int, default=1324,
|
|
129
|
+
help='connect to port (default: 1324)')
|
|
130
|
+
self.sub.add_argument(
|
|
131
|
+
'-i', '--ip', action='store', default='localhost',
|
|
132
|
+
help='connect to ip address (default: locahost)')
|
|
133
|
+
|
|
134
|
+
def run_once(self, options):
|
|
135
|
+
"""Create the module."""
|
|
136
|
+
tag_info = dict(Config(options.tags))
|
|
137
|
+
self.module = Console(options.ip, options.port, tag_info)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _checkout(Module):
|
|
141
|
+
"""Bus Module."""
|
|
142
|
+
|
|
143
|
+
name = 'checkout'
|
|
144
|
+
help = 'create example config files'
|
|
145
|
+
epilog = """
|
|
146
|
+
To add to systemd `f="pymscada-bus" && cp config/$f.service
|
|
147
|
+
/lib/systemd/system && systemctl enable $f && systemctl start
|
|
148
|
+
$f`"""
|
|
149
|
+
config = False
|
|
150
|
+
tags = False
|
|
151
|
+
|
|
152
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
153
|
+
super().__init__(subparser)
|
|
154
|
+
self.sub.add_argument(
|
|
155
|
+
'--overwrite', action='store_true', default=False,
|
|
156
|
+
help='checkout may overwrite files, CARE!')
|
|
157
|
+
self.sub.add_argument(
|
|
158
|
+
'--diff', action='store_true', default=False,
|
|
159
|
+
help='compare default with existing')
|
|
23
160
|
|
|
161
|
+
def run_once(self, options):
|
|
162
|
+
"""Create the module."""
|
|
163
|
+
checkout(overwrite=options.overwrite, diff=options.diff)
|
|
24
164
|
|
|
25
|
-
async def bus(options):
|
|
26
|
-
"""Return bus module."""
|
|
27
|
-
config = Config(options.config)
|
|
28
|
-
return BusServer(**config)
|
|
29
165
|
|
|
166
|
+
class _validate(Module):
|
|
167
|
+
"""Bus Module."""
|
|
30
168
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
config =
|
|
34
|
-
|
|
35
|
-
return WwwServer(tag_info=tag_info, **config)
|
|
169
|
+
name = 'validate'
|
|
170
|
+
help = 'validate config files'
|
|
171
|
+
config = False
|
|
172
|
+
tags = False
|
|
36
173
|
|
|
174
|
+
def __init__(self, subparser: argparse._SubParsersAction):
|
|
175
|
+
super().__init__(subparser)
|
|
176
|
+
self.sub.add_argument(
|
|
177
|
+
'--path', metavar='file',
|
|
178
|
+
help='default is current working directory')
|
|
37
179
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
180
|
+
def run_once(self, options):
|
|
181
|
+
"""Create the module."""
|
|
182
|
+
r, e, p = validate(options.path)
|
|
183
|
+
if r:
|
|
184
|
+
print(f'Config files in {p} valid.')
|
|
185
|
+
else:
|
|
186
|
+
print(e)
|
|
43
187
|
|
|
44
188
|
|
|
45
|
-
|
|
46
|
-
"""
|
|
47
|
-
config = Config(options.config)
|
|
48
|
-
return Files(**config)
|
|
189
|
+
class _LogixClient(Module):
|
|
190
|
+
"""Bus Module."""
|
|
49
191
|
|
|
192
|
+
name = 'logixclient'
|
|
193
|
+
help = 'poll/write to logix devices'
|
|
50
194
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
195
|
+
def run_once(self, options):
|
|
196
|
+
"""Create the module."""
|
|
197
|
+
config = Config(options.config)
|
|
198
|
+
self.module = LogixClient(**config)
|
|
54
199
|
|
|
55
200
|
|
|
56
|
-
|
|
57
|
-
"""
|
|
58
|
-
checkout(overwrite=options.overwrite, diff=options.diff)
|
|
59
|
-
return
|
|
201
|
+
class _ModbusServer(Module):
|
|
202
|
+
"""Bus Module."""
|
|
60
203
|
|
|
204
|
+
name = 'modbusserver'
|
|
205
|
+
help = 'receive modbus messages'
|
|
206
|
+
epilog = """
|
|
207
|
+
Needs `setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/python3.nn` to
|
|
208
|
+
bind to port 502."""
|
|
209
|
+
tags = False
|
|
61
210
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
print(f'Config files in {p} valid.')
|
|
67
|
-
else:
|
|
68
|
-
print(e)
|
|
69
|
-
return
|
|
211
|
+
def run_once(self, options):
|
|
212
|
+
"""Create the module."""
|
|
213
|
+
config = Config(options.config)
|
|
214
|
+
self.module = ModbusServer(**config)
|
|
70
215
|
|
|
71
216
|
|
|
72
|
-
|
|
73
|
-
"""
|
|
74
|
-
config = Config(options.config)
|
|
75
|
-
return ModbusServer(**config)
|
|
217
|
+
class _ModbusClient(Module):
|
|
218
|
+
"""Bus Module."""
|
|
76
219
|
|
|
220
|
+
name = 'modbusclient'
|
|
221
|
+
help = 'poll/write to modbus devices'
|
|
222
|
+
tags = False
|
|
77
223
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
224
|
+
def run_once(self, options):
|
|
225
|
+
"""Create the module."""
|
|
226
|
+
config = Config(options.config)
|
|
227
|
+
self.module = ModbusClient(**config)
|
|
82
228
|
|
|
83
229
|
|
|
84
|
-
|
|
85
|
-
"""
|
|
86
|
-
config = Config(options.config)
|
|
87
|
-
return LogixClient(**config)
|
|
230
|
+
class _PingClient(Module):
|
|
231
|
+
"""Bus Module."""
|
|
88
232
|
|
|
233
|
+
name = 'ping'
|
|
234
|
+
help = 'ping a list of addresses, return time'
|
|
235
|
+
epilog = """
|
|
236
|
+
Needs `setcap CAP_NET_RAW+ep /usr/bin/python3.nn` to open SOCK_RAW
|
|
237
|
+
"""
|
|
238
|
+
tags = False
|
|
89
239
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
240
|
+
def run_once(self, options):
|
|
241
|
+
"""Create the module."""
|
|
242
|
+
if sys.platform.startswith("win"):
|
|
243
|
+
asyncio.set_event_loop_policy(
|
|
244
|
+
asyncio.WindowsSelectorEventLoopPolicy())
|
|
245
|
+
config = Config(options.config)
|
|
246
|
+
self.module = PingClient(**config)
|
|
96
247
|
|
|
97
248
|
|
|
98
|
-
|
|
99
|
-
"""
|
|
100
|
-
config = Config(options.config)
|
|
101
|
-
return SnmpClient(**config)
|
|
249
|
+
class _SnmpClient(Module):
|
|
250
|
+
"""Bus Module."""
|
|
102
251
|
|
|
252
|
+
name = 'snmpclient'
|
|
253
|
+
help = 'poll snmp oids'
|
|
254
|
+
tags = False
|
|
103
255
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
s = parser.add_parser(name, help=help, epilog=epilog)
|
|
109
|
-
s.set_defaults(get_module=call, module=name)
|
|
110
|
-
s.add_argument('--config', metavar='file', default=None,
|
|
111
|
-
help=f"Config file, default is '{name}.yaml'")
|
|
112
|
-
s.add_argument('--tags', metavar='file', default=None,
|
|
113
|
-
help="Tags file, default is 'tags.yaml'")
|
|
114
|
-
s.add_argument('--verbose', action='store_true',
|
|
115
|
-
help="Set level to logging.INFO")
|
|
116
|
-
return s
|
|
256
|
+
def run_once(self, options):
|
|
257
|
+
"""Create the module."""
|
|
258
|
+
config = Config(options.config)
|
|
259
|
+
self.module = SnmpClient(**config)
|
|
117
260
|
|
|
118
261
|
|
|
119
262
|
def args(_version: str):
|
|
@@ -123,41 +266,20 @@ def args(_version: str):
|
|
|
123
266
|
description='Connect IO, logic, applications, and webpage UI',
|
|
124
267
|
epilog=f'Python Mobile SCADA {_version}'
|
|
125
268
|
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
bind to port 502"""],
|
|
141
|
-
['modbusclient', modbusclient, 'poll/write to modbus devices', None],
|
|
142
|
-
['ping', ping, 'ping a list of addresses, return time', """
|
|
143
|
-
Needs `setcap CAP_NET_RAW+ep /usr/bin/python3.nn` to open SOCK_RAW
|
|
144
|
-
"""],
|
|
145
|
-
['logixclient', logixclient, 'poll/write to logix devices', None],
|
|
146
|
-
['snmpclient', snmpclient, 'poll snmp oids', None],
|
|
147
|
-
]:
|
|
148
|
-
modparser = add_subparser_defaults(subparsers, module, func, help,
|
|
149
|
-
epilog)
|
|
150
|
-
if module == 'checkout':
|
|
151
|
-
modparser.add_argument(
|
|
152
|
-
'--overwrite', action='store_true', default=False,
|
|
153
|
-
help='checkout may overwrite files, CARE!')
|
|
154
|
-
modparser.add_argument(
|
|
155
|
-
'--diff', action='store_true', default=False,
|
|
156
|
-
help='compare default with existing')
|
|
157
|
-
elif module == 'validate':
|
|
158
|
-
modparser.add_argument(
|
|
159
|
-
'--path', metavar='file',
|
|
160
|
-
help='default is current working directory')
|
|
269
|
+
s = parser.add_subparsers(title='module')
|
|
270
|
+
_Bus(s)
|
|
271
|
+
_WwwServer(s)
|
|
272
|
+
_History(s)
|
|
273
|
+
_Files(s)
|
|
274
|
+
_OpNotes(s)
|
|
275
|
+
_Console(s)
|
|
276
|
+
_checkout(s)
|
|
277
|
+
_validate(s)
|
|
278
|
+
_LogixClient(s)
|
|
279
|
+
_ModbusServer(s)
|
|
280
|
+
_ModbusClient(s)
|
|
281
|
+
_PingClient(s)
|
|
282
|
+
_SnmpClient(s)
|
|
161
283
|
return parser.parse_args()
|
|
162
284
|
|
|
163
285
|
|
|
@@ -167,13 +289,9 @@ async def run():
|
|
|
167
289
|
logging.warning(f'pymscada {_version} starting')
|
|
168
290
|
options = args(_version)
|
|
169
291
|
if options.verbose:
|
|
170
|
-
logging.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
options.
|
|
175
|
-
|
|
176
|
-
if module is None:
|
|
177
|
-
return
|
|
178
|
-
await module.start()
|
|
179
|
-
await asyncio.get_event_loop().create_future()
|
|
292
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
293
|
+
options.app.run_once(options)
|
|
294
|
+
if options.app.module is not None:
|
|
295
|
+
await options.app.module.start()
|
|
296
|
+
if options.app.await_future:
|
|
297
|
+
await asyncio.get_event_loop().create_future()
|
pymscada/opnotes.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Operator Notes."""
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3 # note that sqlite3 has blocking calls
|
|
4
|
+
from pymscada.bus_client import BusClient
|
|
5
|
+
from pymscada.tag import Tag
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpNotes:
|
|
9
|
+
"""Connect to bus_ip:bus_port, store and provide Operator Notes."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
12
|
+
db: str = None, rta_tag: str = '__opnotes__',
|
|
13
|
+
table: str = 'opnotes') -> None:
|
|
14
|
+
"""
|
|
15
|
+
Connect to bus_ip:bus_port, serve and update operator notes database.
|
|
16
|
+
|
|
17
|
+
TODO
|
|
18
|
+
Write something.
|
|
19
|
+
|
|
20
|
+
Event loop must be running.
|
|
21
|
+
"""
|
|
22
|
+
if db is None:
|
|
23
|
+
raise SystemExit('OpNotes db must be defined')
|
|
24
|
+
logging.warning(f'OpNotes {bus_ip} {bus_port} {db} {rta_tag}')
|
|
25
|
+
self.connection = sqlite3.connect(db)
|
|
26
|
+
self.table = table
|
|
27
|
+
self.cursor = self.connection.cursor()
|
|
28
|
+
query = (
|
|
29
|
+
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
30
|
+
'(oid INTEGER PRIMARY KEY ASC, '
|
|
31
|
+
'datetime INTEGER, '
|
|
32
|
+
'site TEXT, '
|
|
33
|
+
'operator TEXT, '
|
|
34
|
+
'note TEXT)'
|
|
35
|
+
)
|
|
36
|
+
self.cursor.execute(query)
|
|
37
|
+
self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
|
|
38
|
+
self.rta = Tag(rta_tag, dict)
|
|
39
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
40
|
+
|
|
41
|
+
def rta_cb(self, request):
|
|
42
|
+
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
43
|
+
if 'action' not in request:
|
|
44
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
45
|
+
elif request['action'] == 'ADD':
|
|
46
|
+
try:
|
|
47
|
+
with self.connection:
|
|
48
|
+
self.cursor.execute(
|
|
49
|
+
f'INSERT INTO {self.table} (datetime, site, operator, '
|
|
50
|
+
'note) VALUES(:datetime, :site, :operator, :note) '
|
|
51
|
+
'RETURNING *;',
|
|
52
|
+
request)
|
|
53
|
+
res = self.cursor.fetchone()
|
|
54
|
+
self.rta.value = {
|
|
55
|
+
'oid': res[0],
|
|
56
|
+
'datetime': res[1],
|
|
57
|
+
'site': res[2],
|
|
58
|
+
'operator': res[3],
|
|
59
|
+
'note': res[4]
|
|
60
|
+
}
|
|
61
|
+
except sqlite3.IntegrityError as error:
|
|
62
|
+
logging.warning(f'OpNotes rta_cb {error}')
|
|
63
|
+
elif request['action'] == 'MODIFY':
|
|
64
|
+
try:
|
|
65
|
+
with self.connection:
|
|
66
|
+
self.cursor.execute(
|
|
67
|
+
f'REPLACE INTO {self.table} VALUES(:oid, :datetime, '
|
|
68
|
+
':site, :operator, :note) RETURNING *;', request)
|
|
69
|
+
res = self.cursor.fetchone()
|
|
70
|
+
self.rta.value = {
|
|
71
|
+
'oid': res[0],
|
|
72
|
+
'datetime': res[1],
|
|
73
|
+
'site': res[2],
|
|
74
|
+
'operator': res[3],
|
|
75
|
+
'note': res[4]
|
|
76
|
+
}
|
|
77
|
+
except sqlite3.IntegrityError as error:
|
|
78
|
+
logging.warning(f'OpNotes rta_cb {error}')
|
|
79
|
+
elif request['action'] == 'DELETE':
|
|
80
|
+
try:
|
|
81
|
+
with self.connection:
|
|
82
|
+
self.cursor.execute(
|
|
83
|
+
f'DELETE FROM {self.table} WHERE oid = :oid;', request)
|
|
84
|
+
self.rta.value = {'oid': request['oid']}
|
|
85
|
+
except sqlite3.IntegrityError as error:
|
|
86
|
+
logging.warning(f'OpNotes rta_cb {error}')
|
|
87
|
+
elif request['action'] == 'HISTORY':
|
|
88
|
+
tag = Tag(request['reply_tag'], dict)
|
|
89
|
+
try:
|
|
90
|
+
with self.connection:
|
|
91
|
+
self.cursor.execute(
|
|
92
|
+
f'SELECT * FROM {self.table} WHERE datetime < '
|
|
93
|
+
':datetime ORDER BY ABS(datetime - :datetime) '
|
|
94
|
+
'LIMIT 2;', request)
|
|
95
|
+
for res in self.cursor.fetchall():
|
|
96
|
+
tag.value = {
|
|
97
|
+
'oid': res[0],
|
|
98
|
+
'datetime': res[1],
|
|
99
|
+
'site': res[2],
|
|
100
|
+
'operator': res[3],
|
|
101
|
+
'note': res[4]
|
|
102
|
+
}
|
|
103
|
+
except sqlite3.IntegrityError as error:
|
|
104
|
+
logging.warning(f'OpNotes rta_cb {error}')
|
|
105
|
+
|
|
106
|
+
async def start(self):
|
|
107
|
+
"""Async startup."""
|
|
108
|
+
await self.busclient.start()
|
pymscada/protocol_constants.py
CHANGED
|
@@ -3,12 +3,12 @@ Protocol description and protocol constants.
|
|
|
3
3
|
|
|
4
4
|
Bus holds a tag forever, assigns a tag id forever, holds a tag value with len
|
|
5
5
|
< 1000 forever, otherwise wipes periodically. So assign tagnames once, at the
|
|
6
|
-
start of your program; use
|
|
6
|
+
start of your program; use RTA as an update messenger, share whole structures
|
|
7
7
|
rarely.
|
|
8
8
|
|
|
9
9
|
- version 16-bit unsigned int == 0x01
|
|
10
10
|
- command 16-bit unsigned int
|
|
11
|
-
- tag_id 32-bit unsigned int
|
|
11
|
+
- tag_id 32-bit unsigned int 0 is not a valid tag_id
|
|
12
12
|
- size 32-bit unsigned int
|
|
13
13
|
- if size == 0xff continuation mandatory
|
|
14
14
|
- size 0x00 completes an empty continuation
|
|
@@ -23,7 +23,7 @@ command
|
|
|
23
23
|
- CMD_UNSUB id
|
|
24
24
|
- no reply
|
|
25
25
|
- CMD_GET id
|
|
26
|
-
-
|
|
26
|
+
- CMD_RTA id, data is request to author
|
|
27
27
|
- CMD_SUB id
|
|
28
28
|
- reply: SET id and value, value may be None
|
|
29
29
|
- CMD_LIST
|
|
@@ -34,6 +34,7 @@ command
|
|
|
34
34
|
- text$ matches start of tagname
|
|
35
35
|
- text matches anywhere in tagname
|
|
36
36
|
- reply: LIST data as space separated tagnames
|
|
37
|
+
- CMD_LOG data to logging.warning
|
|
37
38
|
"""
|
|
38
39
|
|
|
39
40
|
# Tuning constants
|
|
@@ -43,25 +44,27 @@ MAX_LEN = 65535 - 14 # TODO fix server(?) when 3
|
|
|
43
44
|
CMD_ID = 1 # query / inform tag ID - data is tagname bytes string
|
|
44
45
|
CMD_SET = 2 # set a tag
|
|
45
46
|
CMD_GET = 3 # get a tag
|
|
46
|
-
|
|
47
|
+
CMD_RTA = 4 # request to author
|
|
47
48
|
CMD_SUB = 5 # subscribe to a tag
|
|
48
49
|
CMD_UNSUB = 6 # unsubscribe from a tag
|
|
49
50
|
CMD_LIST = 7 # bus list tags
|
|
50
51
|
CMD_ERR = 8 # action failed
|
|
52
|
+
CMD_LOG = 9 # bus print a logging message
|
|
51
53
|
|
|
52
54
|
CMD_TEXT = {
|
|
53
55
|
1: 'CMD_ID',
|
|
54
56
|
2: 'CMD_SET',
|
|
55
57
|
3: 'CMD_GET',
|
|
56
|
-
4: '
|
|
58
|
+
4: 'CMD_RTA',
|
|
57
59
|
5: 'CMD_SUB',
|
|
58
60
|
6: 'CMD_UNSUB',
|
|
59
61
|
7: 'CMD_LIST',
|
|
60
|
-
8: 'CMD_ERR'
|
|
62
|
+
8: 'CMD_ERR',
|
|
63
|
+
9: 'CMD_LOG'
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
COMMANDS = [CMD_ID, CMD_SET, CMD_GET,
|
|
64
|
-
CMD_ERR]
|
|
66
|
+
COMMANDS = [CMD_ID, CMD_SET, CMD_GET, CMD_RTA, CMD_SUB, CMD_UNSUB, CMD_LIST,
|
|
67
|
+
CMD_ERR, CMD_LOG]
|
|
65
68
|
|
|
66
69
|
# data types
|
|
67
70
|
TYPE_INT = 1 # 64 bit signed integer
|
pymscada/tag.py
CHANGED
|
@@ -18,6 +18,28 @@ TYPES = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def tag_for_web(tagname: str, tag: dict):
|
|
22
|
+
"""Correct tag dictionary in place to be suitable for web client."""
|
|
23
|
+
tag['name'] = tagname
|
|
24
|
+
tag['id'] = None
|
|
25
|
+
if 'desc' not in tag:
|
|
26
|
+
tag['desc'] = tag.name
|
|
27
|
+
if 'multi' in tag:
|
|
28
|
+
tag['type'] = 'int'
|
|
29
|
+
else:
|
|
30
|
+
if 'type' not in tag:
|
|
31
|
+
tag['type'] = 'float'
|
|
32
|
+
else:
|
|
33
|
+
if tag['type'] not in TYPES:
|
|
34
|
+
tag['type'] = 'str'
|
|
35
|
+
if tag['type'] == 'int':
|
|
36
|
+
tag['dp'] = 0
|
|
37
|
+
elif tag['type'] == 'float' and 'dp' not in tag:
|
|
38
|
+
tag['dp'] = 2
|
|
39
|
+
elif tag['type'] == 'str' and 'dp' in tag:
|
|
40
|
+
del tag['dp']
|
|
41
|
+
|
|
42
|
+
|
|
21
43
|
class UniqueTag(type):
|
|
22
44
|
"""Super Tag class only create unique tags for unique tag names."""
|
|
23
45
|
|