pymscada 0.1.11__py3-none-any.whl → 0.1.11b3__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.
pymscada/main.py CHANGED
@@ -1,311 +1,57 @@
1
1
  """Main server entry point."""
2
2
  import argparse
3
3
  import asyncio
4
- from importlib.metadata import version
5
4
  import logging
6
5
  import sys
7
- from pymscada.bus_server import BusServer
8
- from pymscada.checkout import checkout
9
- from pymscada.config import Config
10
- from pymscada.console import Console
11
- from pymscada.files import Files
12
- from pymscada.history import History
13
- from pymscada.opnotes import OpNotes
14
- from pymscada.www_server import WwwServer
15
- from pymscada.iodrivers.accuweather import AccuWeatherClient
16
- from pymscada.iodrivers.logix_client import LogixClient
17
- from pymscada.iodrivers.modbus_client import ModbusClient
18
- from pymscada.iodrivers.modbus_server import ModbusServer
19
- from pymscada.iodrivers.ping_client import PingClient
20
- from pymscada.iodrivers.snmp_client import SnmpClient
21
- from pymscada.validate import validate
22
-
23
-
24
- class Module():
25
- """Default Module."""
26
-
27
- name = None
28
- help = None
29
- epilog = None
30
- module = None
31
- config = True
32
- tags = True
33
- sub = None
34
- await_future = True
35
-
36
- def __init__(self, subparser: argparse._SubParsersAction):
37
- """Add arguments common to all subparsers."""
38
- self.sub = subparser.add_parser(
39
- self.name, help=self.help, epilog=self.epilog)
40
- self.sub.set_defaults(app=self)
41
- if self.config:
42
- self.sub.add_argument(
43
- '--config', metavar='file', default=f'{self.name}.yaml',
44
- help=f"Config file, default is '{self.name}.yaml'")
45
- if self.tags:
46
- self.sub.add_argument(
47
- '--tags', metavar='file', default='tags.yaml',
48
- help="Tags file, default is 'tags.yaml'")
49
- self.sub.add_argument('--verbose', action='store_true',
50
- help="Set level to logging.INFO")
51
-
52
-
53
- class _Bus(Module):
54
- """Bus Server."""
55
-
56
- name = 'bus'
57
- help = 'run the message bus'
58
- tags = False
59
-
60
- def run_once(self, options):
61
- """Create the module."""
62
- config = Config(options.config)
63
- self.module = BusServer(**config)
64
-
65
-
66
- class _WwwServer(Module):
67
- """WWW Server Module."""
68
-
69
- name = 'wwwserver'
70
- help = 'serve web pages'
71
-
72
- def run_once(self, options):
73
- """Create the module."""
74
- config = Config(options.config)
75
- tag_info = dict(Config(options.tags))
76
- self.module = WwwServer(tag_info=tag_info, **config)
77
-
78
-
79
- class _History(Module):
80
- """History Module."""
81
-
82
- name = 'history'
83
- help = 'collect and serve history'
84
-
85
- def run_once(self, options):
86
- """Create the module."""
87
- config = Config(options.config)
88
- tag_info = dict(Config(options.tags))
89
- self.module = History(tag_info=tag_info, **config)
90
-
91
-
92
- class _Files(Module):
93
- """Files Module."""
94
-
95
- name = 'files'
96
- help = 'receive and send files'
97
- tags = False
98
-
99
- def run_once(self, options):
100
- """Create the module."""
101
- config = Config(options.config)
102
- self.module = Files(**config)
103
-
104
-
105
- class _OpNotes(Module):
106
- """Operator Notes Module."""
107
-
108
- name = 'opnotes'
109
- help = 'present and manage operator notes'
110
- tags = False
111
-
112
- def run_once(self, options):
113
- """Create the module."""
114
- config = Config(options.config)
115
- self.module = OpNotes(**config)
116
-
117
-
118
- class _Console(Module):
119
- """Bus Module."""
120
-
121
- name = 'console'
122
- help = 'interactive bus console'
123
- config = False
124
- await_future = False
125
-
126
- def __init__(self, subparser: argparse._SubParsersAction):
127
- super().__init__(subparser)
128
- self.sub.add_argument(
129
- '-p', '--port', action='store', type=int, default=1324,
130
- help='connect to port (default: 1324)')
131
- self.sub.add_argument(
132
- '-i', '--ip', action='store', default='localhost',
133
- help='connect to ip address (default: locahost)')
134
-
135
- def run_once(self, options):
136
- """Create the module."""
137
- tag_info = dict(Config(options.tags))
138
- self.module = Console(options.ip, options.port, tag_info)
139
-
140
-
141
- class _checkout(Module):
142
- """Bus Module."""
143
-
144
- name = 'checkout'
145
- help = 'create example config files'
146
- epilog = """
147
- To add to systemd `f="pymscada-bus" && cp config/$f.service
148
- /lib/systemd/system && systemctl enable $f && systemctl start
149
- $f`"""
150
- config = False
151
- tags = False
152
-
153
- def __init__(self, subparser: argparse._SubParsersAction):
154
- super().__init__(subparser)
155
- self.sub.add_argument(
156
- '--overwrite', action='store_true', default=False,
157
- help='checkout may overwrite files, CARE!')
158
- self.sub.add_argument(
159
- '--diff', action='store_true', default=False,
160
- help='compare default with existing')
161
-
162
- def run_once(self, options):
163
- """Create the module."""
164
- checkout(overwrite=options.overwrite, diff=options.diff)
165
-
166
-
167
- class _validate(Module):
168
- """Bus Module."""
169
-
170
- name = 'validate'
171
- help = 'validate config files'
172
- config = False
173
- tags = False
174
-
175
- def __init__(self, subparser: argparse._SubParsersAction):
176
- super().__init__(subparser)
177
- self.sub.add_argument(
178
- '--path', metavar='file',
179
- help='default is current working directory')
180
-
181
- def run_once(self, options):
182
- """Create the module."""
183
- r, e, p = validate(options.path)
184
- if r:
185
- print(f'Config files in {p} valid.')
186
- else:
187
- print(e)
188
-
189
-
190
- class _AccuWeatherClient(Module):
191
- """Bus Module."""
192
-
193
- name = 'accuweatherclient'
194
- help = 'poll weather information'
195
-
196
- def run_once(self, options):
197
- """Create the module."""
198
- config = Config(options.config)
199
- self.module = AccuWeatherClient(**config)
200
-
201
-
202
- class _LogixClient(Module):
203
- """Bus Module."""
204
-
205
- name = 'logixclient'
206
- help = 'poll/write to logix devices'
207
-
208
- def run_once(self, options):
209
- """Create the module."""
210
- config = Config(options.config)
211
- self.module = LogixClient(**config)
212
-
213
-
214
- class _ModbusServer(Module):
215
- """Bus Module."""
216
-
217
- name = 'modbusserver'
218
- help = 'receive modbus messages'
219
- epilog = """
220
- Needs `setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/python3.nn` to
221
- bind to port 502."""
222
- tags = False
223
-
224
- def run_once(self, options):
225
- """Create the module."""
226
- config = Config(options.config)
227
- self.module = ModbusServer(**config)
228
-
229
-
230
- class _ModbusClient(Module):
231
- """Bus Module."""
232
-
233
- name = 'modbusclient'
234
- help = 'poll/write to modbus devices'
235
- tags = False
236
-
237
- def run_once(self, options):
238
- """Create the module."""
239
- config = Config(options.config)
240
- self.module = ModbusClient(**config)
241
-
242
-
243
- class _PingClient(Module):
244
- """Bus Module."""
245
-
246
- name = 'ping'
247
- help = 'ping a list of addresses, return time'
248
- epilog = """
249
- Needs `setcap CAP_NET_RAW+ep /usr/bin/python3.nn` to open SOCK_RAW
250
- """
251
- tags = False
252
-
253
- def run_once(self, options):
254
- """Create the module."""
255
- if sys.platform.startswith("win"):
256
- asyncio.set_event_loop_policy(
257
- asyncio.WindowsSelectorEventLoopPolicy())
258
- config = Config(options.config)
259
- self.module = PingClient(**config)
260
-
261
-
262
- class _SnmpClient(Module):
263
- """Bus Module."""
264
-
265
- name = 'snmpclient'
266
- help = 'poll snmp oids'
267
- tags = False
268
-
269
- def run_once(self, options):
270
- """Create the module."""
271
- config = Config(options.config)
272
- self.module = SnmpClient(**config)
6
+ from importlib.metadata import version
7
+ from pymscada.module_config import ModuleFactory
273
8
 
274
9
 
275
- def args(_version: str):
10
+ def args():
276
11
  """Read commandline arguments."""
277
12
  parser = argparse.ArgumentParser(
278
13
  prog='pymscada',
279
14
  description='Connect IO, logic, applications, and webpage UI',
280
- epilog=f'Python Mobile SCADA {_version}'
15
+ epilog=f'Python Mobile SCADA {version("pymscada")}'
281
16
  )
282
- s = parser.add_subparsers(title='module')
283
- _Bus(s)
284
- _WwwServer(s)
285
- _History(s)
286
- _Files(s)
287
- _OpNotes(s)
288
- _Console(s)
289
- _checkout(s)
290
- _validate(s)
291
- _AccuWeatherClient(s)
292
- _LogixClient(s)
293
- _ModbusServer(s)
294
- _ModbusClient(s)
295
- _PingClient(s)
296
- _SnmpClient(s)
17
+ factory = ModuleFactory()
18
+ subparsers = parser.add_subparsers(title='module', dest='module_name')
19
+ for _, module_def in factory.modules.items():
20
+ factory.add_module_parser(subparsers, module_def)
297
21
  return parser.parse_args()
298
22
 
299
23
 
300
24
  async def run():
301
- """Run bus and wwwserver."""
302
- _version = version("pymscada")
303
- logging.warning(f'pymscada {_version} starting')
304
- options = args(_version)
305
- if options.verbose:
306
- logging.getLogger().setLevel(logging.INFO)
307
- options.app.run_once(options)
308
- if options.app.module is not None:
309
- await options.app.module.start()
310
- if options.app.await_future:
311
- await asyncio.get_event_loop().create_future()
25
+ """Run the selected module."""
26
+ options = args()
27
+ if not options.module_name:
28
+ print("Error: Please specify a module to run")
29
+ sys.exit(1)
30
+ root_logger = logging.getLogger()
31
+ handler = logging.StreamHandler()
32
+ formatter = logging.Formatter('%(levelname)s:pymscada: %(message)s')
33
+ handler.setFormatter(formatter)
34
+ root_logger.handlers.clear() # Remove any existing handlers
35
+ root_logger.addHandler(handler)
36
+ root_logger.setLevel(logging.INFO)
37
+ logging.info(f'Python Mobile SCADA {version("pymscada")} starting '
38
+ f'{options.module_name}')
39
+ if not options.verbose:
40
+ root_logger.setLevel(logging.WARNING)
41
+ factory = ModuleFactory()
42
+ module = factory.create_module(options.module_name, options)
43
+ if module is not None:
44
+ if hasattr(module, 'start'):
45
+ await module.start()
46
+ if options.module_name in factory.modules:
47
+ module_def = factory.modules[options.module_name]
48
+ if module_def.await_future:
49
+ await asyncio.get_event_loop().create_future()
50
+
51
+
52
+ def main():
53
+ """Entry point."""
54
+ asyncio.run(run())
55
+
56
+ if __name__ == '__main__':
57
+ main()
@@ -0,0 +1,217 @@
1
+ """Module configuration and factory system."""
2
+ from typing import Any, Optional, Type
3
+ import argparse
4
+ from importlib.metadata import version
5
+ import logging
6
+ from pymscada.config import Config
7
+ from pymscada.console import Console
8
+
9
+ class ModuleArgument:
10
+ def __init__(self, args: tuple[str, ...], kwargs: dict[str, Any]):
11
+ self.args = args
12
+ self.kwargs = kwargs
13
+
14
+ class ModuleDefinition:
15
+ """Defines a module's configuration and behavior."""
16
+ def __init__(self, name: str, help: str, module_class: Type[Any], *,
17
+ config: bool = True, tags: bool = True,
18
+ epilog: Optional[str] = None,
19
+ extra_args: list[ModuleArgument] = None,
20
+ await_future: bool = True):
21
+ self.name = name
22
+ self.help = help
23
+ self.module_class = module_class
24
+ self.config = config
25
+ self.tags = tags
26
+ self.epilog = epilog
27
+ self.extra_args = extra_args
28
+ self.await_future = await_future
29
+
30
+ def create_module_registry():
31
+ """Create the central module registry with lazy imports."""
32
+ return [
33
+ ModuleDefinition(
34
+ name='bus',
35
+ help='run the message bus',
36
+ module_class='pymscada.bus_server:BusServer',
37
+ tags=False
38
+ ),
39
+ ModuleDefinition(
40
+ name='wwwserver',
41
+ help='serve web pages',
42
+ module_class='pymscada.www_server:WwwServer'
43
+ ),
44
+ ModuleDefinition(
45
+ name='history',
46
+ help='save tag changes to database',
47
+ module_class='pymscada.history:History'
48
+ ),
49
+ ModuleDefinition(
50
+ name='files',
51
+ help='serve files',
52
+ module_class='pymscada.files:Files',
53
+ tags=False
54
+ ),
55
+ ModuleDefinition(
56
+ name='opnotes',
57
+ help='operator notes',
58
+ module_class='pymscada.opnotes:OpNotes',
59
+ tags=False
60
+ ),
61
+ ModuleDefinition(
62
+ name='validate',
63
+ help='validate config files',
64
+ module_class='pymscada.validate:validate',
65
+ config=False,
66
+ tags=False,
67
+ extra_args=[
68
+ ModuleArgument(
69
+ ('--path',),
70
+ {'metavar': 'file', 'help': 'default is current working directory'}
71
+ )
72
+ ]
73
+ ),
74
+ ModuleDefinition(
75
+ name='checkout',
76
+ help='create example config files',
77
+ module_class='pymscada.checkout:checkout',
78
+ config=False,
79
+ tags=False,
80
+ epilog="""To add to systemd `f="pymscada-bus" && cp config/$f.service
81
+ /lib/systemd/system && systemctl enable $f && systemctl start $f`""",
82
+ extra_args=[
83
+ ModuleArgument(
84
+ ('--overwrite',),
85
+ {'action': 'store_true', 'default': False,
86
+ 'help': 'checkout may overwrite files, CARE!'}
87
+ ),
88
+ ModuleArgument(
89
+ ('--diff',),
90
+ {'action': 'store_true', 'default': False,
91
+ 'help': 'compare default with existing'}
92
+ )
93
+ ]
94
+ ),
95
+ ModuleDefinition(
96
+ name='accuweatherclient',
97
+ help='poll weather information',
98
+ module_class='pymscada.iodrivers.accuweather:AccuWeatherClient',
99
+ tags=False
100
+ ),
101
+ ModuleDefinition(
102
+ name='logixclient',
103
+ help='poll/write to logix devices',
104
+ module_class='pymscada.iodrivers.logix_client:LogixClient',
105
+ tags=False
106
+ ),
107
+ ModuleDefinition(
108
+ name='modbusclient',
109
+ help='poll/write modbus devices',
110
+ module_class='pymscada.iodrivers.modbus_client:ModbusClient',
111
+ tags=False
112
+ ),
113
+ ModuleDefinition(
114
+ name='modbusserver',
115
+ help='serve modbus devices',
116
+ module_class='pymscada.iodrivers.modbus_server:ModbusServer',
117
+ tags=False
118
+ ),
119
+ ModuleDefinition(
120
+ name='openweatherclient',
121
+ help='poll OpenWeather current and forecast data',
122
+ module_class='pymscada.iodrivers.openweather:OpenWeatherClient',
123
+ tags=False
124
+ ),
125
+ ModuleDefinition(
126
+ name='pingclient',
127
+ help='ping network devices',
128
+ module_class='pymscada.iodrivers.ping_client:PingClient',
129
+ tags=False
130
+ ),
131
+ ModuleDefinition(
132
+ name='snmpclient',
133
+ help='poll SNMP devices',
134
+ module_class='pymscada.iodrivers.snmp_client:SnmpClient',
135
+ tags=False
136
+ ),
137
+ ModuleDefinition(
138
+ name='console',
139
+ help='interactive bus console',
140
+ module_class='pymscada.console:Console',
141
+ config=False,
142
+ await_future=False,
143
+ extra_args=[
144
+ ModuleArgument(
145
+ ('-p', '--port'),
146
+ {'action': 'store', 'type': int, 'default': 1324,
147
+ 'help': 'connect to port (default: 1324)'}
148
+ ),
149
+ ModuleArgument(
150
+ ('-i', '--ip'),
151
+ {'action': 'store', 'default': 'localhost',
152
+ 'help': 'connect to ip address (default: localhost)'}
153
+ )
154
+ ]
155
+ ),
156
+ ]
157
+
158
+ class ModuleFactory:
159
+ """Creates and manages module instances."""
160
+
161
+ def __init__(self):
162
+ self.modules = {m.name: m for m in create_module_registry()}
163
+
164
+ def add_module_parser(self, subparser: argparse._SubParsersAction,
165
+ module_def: ModuleDefinition) -> argparse.ArgumentParser:
166
+ """Add a parser for a module with its arguments."""
167
+ parser = subparser.add_parser(
168
+ module_def.name,
169
+ help=module_def.help,
170
+ epilog=module_def.epilog
171
+ )
172
+ if module_def.config:
173
+ parser.add_argument(
174
+ '--config',
175
+ metavar='file',
176
+ default=f'{module_def.name}.yaml',
177
+ help=f"Config file, default is '{module_def.name}.yaml'"
178
+ )
179
+ if module_def.tags:
180
+ parser.add_argument(
181
+ '--tags',
182
+ metavar='file',
183
+ default='tags.yaml',
184
+ help="Tags file, default is 'tags.yaml'"
185
+ )
186
+ parser.add_argument(
187
+ '--verbose',
188
+ action='store_true',
189
+ help="Set level to logging.INFO"
190
+ )
191
+ if module_def.extra_args:
192
+ for arg in module_def.extra_args:
193
+ parser.add_argument(*arg.args, **arg.kwargs)
194
+ return parser
195
+
196
+ def create_module(self, module_name: str, options: argparse.Namespace):
197
+ """Create a module instance based on configuration and options."""
198
+ module_def = self.modules[module_name]
199
+ logging.info(f'Python Mobile SCADA {version("pymscada")} '
200
+ f'starting {module_def.name}')
201
+ # Import the module class only when needed
202
+ if isinstance(module_def.module_class, str):
203
+ module_path, class_name = module_def.module_class.split(':')
204
+ module = __import__(module_path, fromlist=[class_name])
205
+ actual_class = getattr(module, class_name)
206
+ else:
207
+ actual_class = module_def.module_class
208
+
209
+ kwargs = {}
210
+ if module_def.config:
211
+ kwargs.update(Config(options.config))
212
+ if module_def.tags:
213
+ kwargs['tag_info'] = dict(Config(options.tags))
214
+ if module_name == 'console':
215
+ return Console(options.ip, options.port,
216
+ kwargs.get('tag_info',{}))
217
+ return actual_class(**kwargs)
@@ -16,17 +16,17 @@ rarely.
16
16
  - data size of 8-bit char
17
17
 
18
18
  command
19
- - CMD_ID data is tagname
19
+ - CMD.ID data is tagname
20
20
  - reply: CMD_ID with tag_id and data as tagname
21
- - CMD_SET id, data is typed or json packed
21
+ - CMD.SET id, data is typed or json packed
22
22
  - no reply
23
- - CMD_UNSUB id
23
+ - CMD.UNSUB id
24
24
  - no reply
25
- - CMD_GET id
26
- - CMD_RTA id, data is request to author
27
- - CMD_SUB id
25
+ - CMD.GET id
26
+ - CMD.RTA id, data is request to author
27
+ - CMD.SUB id
28
28
  - reply: SET id and value, value may be None
29
- - CMD_LIST
29
+ - CMD.LIST
30
30
  - size == 0x00
31
31
  - tags with values newer than time_us
32
32
  - size > 0x00
@@ -34,43 +34,33 @@ 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
+ - CMD.LOG data to logging.warning
38
38
  """
39
39
 
40
40
  # Tuning constants
41
41
  MAX_LEN = 65535 - 14 # TODO fix server(?) when 3
42
42
 
43
- # Network protocol commands
44
- CMD_ID = 1 # query / inform tag ID - data is tagname bytes string
45
- CMD_SET = 2 # set a tag
46
- CMD_GET = 3 # get a tag
47
- CMD_RTA = 4 # request to author
48
- CMD_SUB = 5 # subscribe to a tag
49
- CMD_UNSUB = 6 # unsubscribe from a tag
50
- CMD_LIST = 7 # bus list tags
51
- CMD_ERR = 8 # action failed
52
- CMD_LOG = 9 # bus print a logging message
43
+ from enum import IntEnum
53
44
 
54
- CMD_TEXT = {
55
- 1: 'CMD_ID',
56
- 2: 'CMD_SET',
57
- 3: 'CMD_GET',
58
- 4: 'CMD_RTA',
59
- 5: 'CMD_SUB',
60
- 6: 'CMD_UNSUB',
61
- 7: 'CMD_LIST',
62
- 8: 'CMD_ERR',
63
- 9: 'CMD_LOG'
64
- }
45
+ class COMMAND(IntEnum):
46
+ ID = 1 # query / inform tag ID - data is tagname bytes string
47
+ SET = 2 # set a tag
48
+ GET = 3 # get a tag
49
+ RTA = 4 # request to author
50
+ SUB = 5 # subscribe to a tag
51
+ UNSUB = 6 # unsubscribe from a tag
52
+ LIST = 7 # bus list tags
53
+ ERR = 8 # action failed
54
+ LOG = 9 # bus print a logging message
65
55
 
66
- COMMANDS = [CMD_ID, CMD_SET, CMD_GET, CMD_RTA, CMD_SUB, CMD_UNSUB, CMD_LIST,
67
- CMD_ERR, CMD_LOG]
56
+ @staticmethod
57
+ def text(cmd) -> str:
58
+ """Return command text description for enum or int."""
59
+ return COMMAND(cmd).name
68
60
 
69
- # data types
70
- TYPE_INT = 1 # 64 bit signed integer
71
- TYPE_FLOAT = 2 # 64 bit IEEE float
72
- TYPE_STR = 3 # string
73
- TYPE_BYTES = 4
74
- TYPE_JSON = 5
75
-
76
- TYPES = [TYPE_INT, TYPE_FLOAT, TYPE_STR, TYPE_BYTES, TYPE_JSON]
61
+ class TYPE(IntEnum):
62
+ INT = 1 # 64 bit signed integer
63
+ FLOAT = 2 # 64 bit IEEE float
64
+ STR = 3 # string
65
+ BYTES = 4 # raw bytes
66
+ JSON = 5 # JSON encoded data