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

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

Potentially problematic release.


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

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
- def args():
21
- """Read commandline arguments."""
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
- async def run():
49
- """Run bus and wwwserver."""
50
- options = args()
51
- if options.verbose:
52
- logging.basicConfig(level=logging.INFO)
53
- if options.config is None:
54
- options.config = f'{options.module}.yaml'
55
- if options.tags is None:
56
- options.tags = 'tags.yaml'
57
- if options.module == 'bus':
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
- elif options.module == 'console':
61
- module = Console()
62
- elif options.module == 'wwwserver':
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
- elif options.module == 'history':
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
- elif options.module == 'files':
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
- elif options.module == 'logixclient':
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 = LogixClient(**config)
76
- elif options.module == 'modbusclient':
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
- elif options.module == 'modbusserver':
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 = ModbusServer(**config)
82
- elif options.module == 'snmpclient':
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 = SnmpClient(**config)
85
- elif options.module == 'simulate':
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
- tag_info = dict(Config(options.tags))
88
- module = Simulate(tag_info=tag_info, **config)
89
- elif options.module == 'checkout':
90
- checkout(overwrite=options.overwrite)
91
- return
92
- elif options.module == 'validate':
93
- r, e = validate(options.path)
94
- if r == True:
95
- print(f'Config files in {options.path} valid.')
96
- else:
97
- print(e)
98
- return
99
- else:
100
- logging.warning(f'no {options.module}')
101
- await module.start()
102
- await asyncio.get_event_loop().create_future()
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()
pymscada/validate.py CHANGED
@@ -51,7 +51,7 @@ TAG_SCHEMA = {
51
51
  'keysrules': {
52
52
  'type': 'string',
53
53
  # tag name discovery, save for later checking
54
- 'ms_tagname': True
54
+ 'ms_tagname': 'save'
55
55
  },
56
56
  'valuesrules': {
57
57
  'type': 'dict',
@@ -67,7 +67,7 @@ TAG_SCHEMA = {
67
67
  BUS_SCHEMA = {
68
68
  'type': 'dict',
69
69
  'schema': {
70
- 'ip': {'type': 'string', 'ms_ip': True},
70
+ 'ip': {'type': 'string', 'ms_ip': 'ipv4 none'},
71
71
  'port': {'type': 'integer', 'min': 1024, 'max': 65536}
72
72
  }
73
73
  }
@@ -88,12 +88,12 @@ VALUESETFILES_LIST = {
88
88
  'allowed': ['value', 'setpoint', 'files'],
89
89
  },
90
90
  # tagname must have been found in parsing tags.yaml
91
- 'tagname': {'type': 'string', 'ms_tagname': False}
91
+ 'tagname': {'type': 'string', 'ms_tagname': 'exists'}
92
92
  }
93
93
  SELECTDICT_LIST = {
94
94
  'type': {'type': 'string', 'allowed': ['selectdict']},
95
95
  # tagname must have been found in parsing tags.yaml
96
- 'tagname': {'type': 'string', 'ms_tagname': False},
96
+ 'tagname': {'type': 'string', 'ms_tagname': 'exists'},
97
97
  'opts': {
98
98
  'type': 'dict',
99
99
  # 'schema': {
@@ -138,7 +138,7 @@ UPLOT_LIST = {
138
138
  'type': 'dict',
139
139
  'schema': {
140
140
  # tagname must have been found in parsing tags.yaml
141
- 'tagname': {'type': 'string', 'ms_tagname': False},
141
+ 'tagname': {'type': 'string', 'ms_tagname': 'exists'},
142
142
  'label': {'type': 'string', 'required': False},
143
143
  'scale': {'type': 'string', 'required': False},
144
144
  'color': {'type': 'string', 'required': False},
@@ -170,9 +170,9 @@ LIST_WWWSERVER = {
170
170
  WWWSERVER_SCHEMA = {
171
171
  'type': 'dict',
172
172
  'schema': {
173
- 'bus_ip': {'type': 'string', 'ms_ip': True},
173
+ 'bus_ip': {'type': 'string', 'ms_ip': 'none ipv4'},
174
174
  'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536},
175
- 'ip': {'type': 'string', 'ms_ip': True},
175
+ 'ip': {'type': 'string', 'ms_ip': 'none ipv4'},
176
176
  'port': {'type': 'integer', 'min': 1024, 'max': 65536},
177
177
  'get_path': {'nullable': True},
178
178
  'paths': {'type': 'list', 'allowed': ['history', 'config', 'pdf']},
@@ -186,7 +186,7 @@ WWWSERVER_SCHEMA = {
186
186
  HISTORY_SCHEMA = {
187
187
  'type': 'dict',
188
188
  'schema': {
189
- 'bus_ip': {'type': 'string', 'ms_ip': True},
189
+ 'bus_ip': {'type': 'string', 'ms_ip': 'none ipv4'},
190
190
  'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536},
191
191
  'path': {'type': 'string'},
192
192
  }
@@ -195,7 +195,7 @@ HISTORY_SCHEMA = {
195
195
  MODBUSSERVER_SCHEMA = {
196
196
  'type': 'dict',
197
197
  'schema': {
198
- 'bus_ip': {'type': 'string', 'ms_ip': True, 'nullable': True},
198
+ 'bus_ip': {'type': 'string', 'ms_ip': 'none ipv4', 'nullable': True},
199
199
  'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536,
200
200
  'nullable': True},
201
201
  'path': {'type': 'string'},
@@ -219,7 +219,7 @@ MODBUSSERVER_SCHEMA = {
219
219
  'type': 'dict',
220
220
  'keysrules': {
221
221
  'type': 'string',
222
- 'ms_tagname': False
222
+ 'ms_tagname': 'none'
223
223
  },
224
224
  'valuesrules': {
225
225
  'type': 'dict',
@@ -235,7 +235,7 @@ MODBUSSERVER_SCHEMA = {
235
235
  MODBUSCLIENT_SCHEMA = {
236
236
  'type': 'dict',
237
237
  'schema': {
238
- 'bus_ip': {'type': 'string', 'ms_ip': True, 'nullable': True},
238
+ 'bus_ip': {'type': 'string', 'ms_ip': 'ipv4'},
239
239
  'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536,
240
240
  'nullable': True},
241
241
  'path': {'type': 'string'},
@@ -249,11 +249,48 @@ MODBUSCLIENT_SCHEMA = {
249
249
  'port': {},
250
250
  'tcp_udp': {'type': 'string', 'allowed': ['tcp', 'udp']},
251
251
  'rate': {},
252
- 'read': {
252
+ 'poll': {
253
253
  'type': 'list',
254
254
  'schema': {}
255
- },
256
- 'writeok': {
255
+ }
256
+ }
257
+ }
258
+ },
259
+ 'tags': {
260
+ 'type': 'dict',
261
+ 'keysrules': {
262
+ 'type': 'string',
263
+ 'ms_tagname': 'exists'
264
+ },
265
+ 'valuesrules': {
266
+ 'type': 'dict',
267
+ 'schema': {
268
+ 'type': {},
269
+ 'read': {},
270
+ 'write': {}
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ SNMPCLIENT_SCHEMA = {
278
+ 'type': 'dict',
279
+ 'schema': {
280
+ 'bus_ip': {'type': 'string', 'ms_ip': 'ipv4'},
281
+ 'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536,
282
+ 'nullable': True},
283
+ 'path': {'type': 'string'},
284
+ 'rtus': {
285
+ 'type': 'list',
286
+ 'schema': {
287
+ 'type': 'dict',
288
+ 'schema': {
289
+ 'name': {},
290
+ 'ip': {},
291
+ 'community': {},
292
+ 'rate': {},
293
+ 'poll': {
257
294
  'type': 'list',
258
295
  'schema': {}
259
296
  }
@@ -264,44 +301,91 @@ MODBUSCLIENT_SCHEMA = {
264
301
  'type': 'dict',
265
302
  'keysrules': {
266
303
  'type': 'string',
267
- 'ms_tagname': False
304
+ 'ms_tagname': 'exists'
268
305
  },
269
306
  'valuesrules': {
270
307
  'type': 'dict',
271
308
  'schema': {
272
309
  'type': {},
273
- 'addr': {}
274
- },
310
+ 'read': {}
311
+ }
275
312
  }
276
- }
313
+ }
277
314
  }
278
315
  }
279
316
 
317
+ LOGIXCLIENT_SCHEMA = {
318
+ 'type': 'dict',
319
+ 'schema': {
320
+ 'bus_ip': {'type': 'string', 'ms_ip': 'ipv4'},
321
+ 'bus_port': {'type': 'integer', 'min': 1024, 'max': 65536,
322
+ 'nullable': True},
323
+ 'path': {'type': 'string'},
324
+ 'rtus': {
325
+ 'type': 'list',
326
+ 'schema': {
327
+ 'type': 'dict',
328
+ 'schema': {
329
+ 'name': {},
330
+ 'ip': {},
331
+ 'rate': {},
332
+ 'poll': {
333
+ 'type': 'list',
334
+ 'schema': {}
335
+ }
336
+ }
337
+ }
338
+ },
339
+ 'tags': {
340
+ 'type': 'dict',
341
+ 'keysrules': {
342
+ 'type': 'string',
343
+ 'ms_tagname': 'exists'
344
+ },
345
+ 'valuesrules': {
346
+ 'type': 'dict',
347
+ 'schema': {
348
+ 'type': {},
349
+ 'read': {},
350
+ 'write': {}
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+
280
358
  class MsValidator(Validator):
281
- """Add additional application checks"""
359
+ """Additional application checks."""
360
+
282
361
  ms_tagnames = {}
362
+ ms_notagcheck = {}
283
363
 
284
364
  def _validate_ms_tagname(self, constraint, field, value):
285
- """ Test tagname exists, capture when true.
365
+ """
366
+ Test tagname exists, capture when true.
286
367
 
287
368
  The rule's arguments are validated against this schema:
288
- {'type': 'boolean'}
369
+ {'type': 'string'}
289
370
  """
290
371
  if '.' in field:
291
372
  self._error(field, "'.' invalid in tag definition.")
292
- if constraint:
373
+ if constraint == 'save':
293
374
  if field in self.ms_tagnames:
294
375
  self._error(field, 'attempt to redefine')
295
376
  else:
296
377
  self.ms_tagnames[field] = {'type': None}
297
- else:
378
+ elif constraint == 'exists':
298
379
  if value not in self.ms_tagnames:
299
380
  self._error(field, 'tag was not defined in tags.yaml')
300
- else:
301
- pass
381
+ elif constraint == 'none':
382
+ pass
383
+ else:
384
+ pass
302
385
 
303
386
  def _validate_ms_tagtype(self, constraint, field, value):
304
- """ Test tagname type, capture when true.
387
+ """
388
+ Test tagname type, capture when true.
305
389
 
306
390
  The rule's arguments are validated against this schema:
307
391
  {'type': 'boolean'}
@@ -318,18 +402,22 @@ class MsValidator(Validator):
318
402
  pass
319
403
 
320
404
  def _validate_ms_ip(self, constraint, field, value):
321
- """ Test session.inet_aton works for the address.
405
+ """
406
+ Test session.inet_aton works for the address.
322
407
 
323
408
  The rule's arguments are validated against this schema:
324
- {'type': 'boolean'}
409
+ {'type': 'string'}
325
410
  """
326
- try:
327
- inet_aton(value)
328
- except (OSError, TypeError):
329
- self._error(field, 'ip address fails socket.inet_aton')
411
+ if value is None and 'none' in constraint:
412
+ pass
413
+ elif 'ipv4' in constraint:
414
+ try:
415
+ inet_aton(value)
416
+ except (OSError, TypeError):
417
+ self._error(field, 'ip address fails socket.inet_aton')
330
418
 
331
419
 
332
- def validate(path: str=None):
420
+ def validate(path: str = None):
333
421
  """Validate."""
334
422
  s = {
335
423
  'tags': TAG_SCHEMA,
@@ -338,8 +426,10 @@ def validate(path: str=None):
338
426
  'history': HISTORY_SCHEMA,
339
427
  'modbusserver': MODBUSSERVER_SCHEMA,
340
428
  'modbusclient': MODBUSCLIENT_SCHEMA,
429
+ 'snmpclient': SNMPCLIENT_SCHEMA,
430
+ 'logixclient': LOGIXCLIENT_SCHEMA,
341
431
  }
342
- prefix = ''
432
+ prefix = './'
343
433
  if path is not None:
344
434
  prefix = path + '/'
345
435
  c = {
@@ -349,8 +439,10 @@ def validate(path: str=None):
349
439
  'history': dict(Config(f'{prefix}history.yaml')),
350
440
  'modbusserver': dict(Config(f'{prefix}modbusserver.yaml')),
351
441
  'modbusclient': dict(Config(f'{prefix}modbusclient.yaml')),
442
+ 'snmpclient': dict(Config(f'{prefix}snmpclient.yaml')),
443
+ 'logixclient': dict(Config(f'{prefix}logixclient.yaml')),
352
444
  }
353
445
  v = MsValidator(s)
354
446
  res = v.validate(c)
355
447
  wdy = dump(v.errors) # , default_flow_style=False)
356
- return res, wdy
448
+ return res, wdy, prefix