mmcb-rs232-avt 1.0.20__py3-none-any.whl → 1.1.38__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.
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Manage access to serial ports for MMCB PID control loops which are running
4
+ asynchronously, and will compete for access to serial ports.
5
+
6
+ There is one PSU/instrument for each serial port, so use one worker per port
7
+ to serialise requests since an individual RS232 serial port will not tolerate
8
+ concurrent access. Concurrent access to different serial ports is allowable.
9
+
10
+ The clients on the left hand side represent PID loop monitoring/control. A
11
+ broker routes traffic between the clients and workers.
12
+
13
+ This script runs the broker and spawns one worker per serial port as based on
14
+ the cached equipment configuration.
15
+
16
+ +--------+ +--------------+
17
+ | | | worker |
18
+ | o----| /dev/ttyUSB0 |
19
+ | | +--------------+
20
+ +----------+ | |
21
+ | client 1 o----o | +--------------+
22
+ +----------+ | | | worker |
23
+ | o----| /dev/ttyUSB1 |
24
+ +----------+ | | +--------------+
25
+ | client 2 +----o broker |
26
+ +----------+ | | ......
27
+ | |
28
+ +----------+ | | +--------------+
29
+ | client 3 +----o | | worker |
30
+ +----------+ | o----| /dev/ttyUSB6 |
31
+ +--------+ +--------------+
32
+ """
33
+
34
+ import argparse
35
+ import fcntl
36
+ import multiprocessing as mp
37
+ import queue
38
+ import threading
39
+ import time
40
+
41
+ import serial
42
+ import zmq
43
+
44
+ from mmcbrs232 import common
45
+ from mmcbrs232 import mdbroker
46
+ from mmcbrs232 import psuset
47
+ from mmcbrs232 import psustat
48
+ from mmcbrs232 import ult80 as chiller
49
+ from mmcbrs232 import mdwrkapi
50
+
51
+
52
+ ###############################################################################
53
+ # command line handler
54
+ ###############################################################################
55
+
56
+
57
+ def check_arguments():
58
+ """
59
+ handle command line options
60
+
61
+ --------------------------------------------------------------------------
62
+ args : none
63
+ --------------------------------------------------------------------------
64
+ returns
65
+ settings : dictionary
66
+ contains core information about the test environment
67
+ --------------------------------------------------------------------------
68
+ """
69
+ parser = argparse.ArgumentParser(
70
+ description=(
71
+ 'ZeroMQ server (broker and workers) for the ATLAS Pixels '
72
+ 'multi-module cycling box serial port devices. This server '
73
+ 'manages concurrent access to serial port resources for all '
74
+ 'attached clients. Commands line scripts emulated are psuset, '
75
+ 'psustat and ult80. WARNING: ANY attached client has the authority '
76
+ 'to change ANY power supply or chiller parameter at ANY time. '
77
+ 'Clients (PID control loops) running concurrently must ensure that '
78
+ 'they only modify their own resources, and not those of other '
79
+ 'clients.'
80
+ )
81
+ )
82
+ parser.add_argument(
83
+ '-v', '--verbose',
84
+ action='store_true',
85
+ help='Enable logging.'
86
+ )
87
+ parser.add_argument(
88
+ '-n', '--nolock',
89
+ action='store_true',
90
+ help=(
91
+ 'Disable the use of the global rs232 lock. '
92
+ 'For safety this script will use the global rs232 lock by default. '
93
+ 'This lock is a coarse measure that should prevent concurrent '
94
+ 'serial port access, even to different ports, by this server and '
95
+ 'other command line scripts that may access serial port resources. '
96
+ 'When running multiple concurrent asynchronous PID control '
97
+ 'scripts, using -n allows optimal sharing of serial port resources '
98
+ 'between those scripts.'
99
+ )
100
+ )
101
+
102
+ return parser.parse_args()
103
+
104
+
105
+ ###############################################################################
106
+ # support for commands: psuset, psustat, ult80
107
+ ###############################################################################
108
+
109
+
110
+ def default():
111
+ """
112
+ ---------------------------------------------------------------------------
113
+ args : none
114
+ ---------------------------------------------------------------------------
115
+ returns : dict
116
+ ---------------------------------------------------------------------------
117
+ """
118
+ return {
119
+ 'alias': None,
120
+ 'channel': None,
121
+ 'current_limit': None,
122
+ 'debug': None,
123
+ 'decimal_places': 2,
124
+ 'forwardbias': False,
125
+ 'immediate': False,
126
+ 'manufacturer': None,
127
+ 'model': None,
128
+ 'on': None,
129
+ 'peltier': False,
130
+ 'port': None,
131
+ 'quiet': False,
132
+ 'rear': None,
133
+ 'reset': False,
134
+ 'serial': None,
135
+ 'settlingtime': 0.5,
136
+ 'verbose': None,
137
+ 'voltage': None,
138
+ 'voltspersecond': 10
139
+ }
140
+
141
+
142
+ def psuset_communicate(settings, pipeline, channel, setpsu):
143
+ """
144
+ Send command to power supply, do not perform checking of set values..
145
+
146
+ ---------------------------------------------------------------------------
147
+ args
148
+ settings : dict
149
+ pipeline : class Pipeline
150
+ channel : instance of class Channel
151
+ setpsu : dict
152
+ lookup table containing functions to call for high/low-voltage PSUs
153
+ ---------------------------------------------------------------------------
154
+ returns
155
+ success : bool
156
+ ---------------------------------------------------------------------------
157
+ """
158
+ with serial.Serial(port=channel.port) as ser:
159
+ ser.apply_settings(channel.config)
160
+ ser.reset_input_buffer()
161
+ ser.reset_output_buffer()
162
+ success = setpsu[channel.category](
163
+ settings, pipeline, ser, channel, slow=False
164
+ )
165
+
166
+ return success
167
+
168
+
169
+ def handle_psuset_request(pipeline, command, psus, channels):
170
+ """
171
+ Process psuset command using the existing psuset code.
172
+ """
173
+ # common.unique only removes items from the top level dict/list but does
174
+ # not change the contents, hence the shallow copy.
175
+ local_psus = psus.copy()
176
+ local_channels = channels.copy()
177
+
178
+ # -------------------------------------------------------------------------
179
+ # parse psuset command, parameters returned in settings
180
+ # -------------------------------------------------------------------------
181
+
182
+ settings = default()
183
+ psuset.check_arguments(settings, command)
184
+
185
+ # -------------------------------------------------------------------------
186
+ # basic filtering
187
+ # -------------------------------------------------------------------------
188
+
189
+ single = common.unique(settings, local_psus, local_channels)
190
+
191
+ if not single:
192
+ # could not identify single PSU channel from supplied arguments
193
+ return False
194
+
195
+ try:
196
+ channel = local_channels[0]
197
+ except IndexError:
198
+ # supplied arguments could not be matched to any PSU channel
199
+ return False
200
+
201
+ # -------------------------------------------------------------------------
202
+ # single PSU channel identified: communicate with PSU
203
+ # -------------------------------------------------------------------------
204
+
205
+ setpsu = {'lvpsu': psuset.configure_lvpsu, 'hvpsu': psuset.configure_hvpsu}
206
+
207
+ return global_lock_wrapper(
208
+ pipeline.use_global_serial_port_lock,
209
+ lambda: psuset_communicate(settings, pipeline, channel, setpsu)
210
+ )
211
+
212
+
213
+ def handle_psustat_request(pipeline, command, psus, channels):
214
+ """
215
+ Mutable variables command and psus must never be changed.
216
+
217
+ --------------------------------------------------------------------------
218
+ args
219
+ pipeline :
220
+ command : str
221
+ psus : dict
222
+ channels : list
223
+ t0 : float
224
+ --------------------------------------------------------------------------
225
+ returns
226
+ --------------------------------------------------------------------------
227
+ """
228
+ # common.unique only removes items from the top level dict/list but does
229
+ # not change the contents, hence the shallow copy.
230
+ local_psus = psus.copy()
231
+ local_channels = channels.copy()
232
+
233
+ settings = {
234
+ 'alias': None,
235
+ 'channel': None,
236
+ 'manufacturer': None,
237
+ 'model': None,
238
+ 'port': None,
239
+ 'serial': None,
240
+ 'time': None,
241
+ }
242
+
243
+ # this call returns configuration in settings
244
+ psustat.check_arguments(settings, command)
245
+
246
+ # does the user-supplied command identify a single PSU channel?
247
+ single = common.unique(settings, local_psus, local_channels)
248
+
249
+ # If filter is False, the supplied command did not attempt to identify a
250
+ # single channel, and therefore should be ignored. FIXME improve this test
251
+ if settings['filter'] and not single:
252
+ print('PSUSTAT rejected')
253
+ return False
254
+
255
+ return global_lock_wrapper(
256
+ pipeline.use_global_serial_port_lock,
257
+ lambda: psustat.power_supply_channel_status(
258
+ settings, pipeline, local_psus, local_channels, common.ANSIColours
259
+ )
260
+ )
261
+
262
+
263
+ def ult80_communicate(args):
264
+ """
265
+ Send command to the ULT80 chiller.
266
+
267
+ --------------------------------------------------------------------------
268
+ args
269
+ args : <class 'argparse.Namespace'>
270
+ --------------------------------------------------------------------------
271
+ returns
272
+ rval : float or None
273
+ --------------------------------------------------------------------------
274
+
275
+ """
276
+ ult80 = chiller.Ult80()
277
+ rval = None
278
+
279
+ if args.read_internal_temperature:
280
+ rval = ult80.read_internal_temperature()
281
+ elif args.read_setpoint:
282
+ rval = ult80.read_setpoint_control_point()
283
+ elif args.set_setpoint:
284
+ rval = ult80.set_setpoint_control_point(float(args.set_setpoint[0]))
285
+
286
+ return rval
287
+
288
+
289
+ def handle_ult80_request(pipeline, command, psus, channels):
290
+ """
291
+ Process psuset command using the existing psuset code.
292
+
293
+ Protect the serial port interaction using the global serial port lock if
294
+ requested.
295
+
296
+ --------------------------------------------------------------------------
297
+ args
298
+ pipeline : class Production
299
+ command : str
300
+ e.g. 'ult80 -i', 'ult80 -r' or 'ult80 -s <value>'
301
+ psus : dict
302
+ Details of the PSU for this serial port (this dict will only have
303
+ a single entry)
304
+ channels : list of class Channel
305
+ details of the PSU channels for this serial port
306
+ --------------------------------------------------------------------------
307
+ returns
308
+ --------------------------------------------------------------------------
309
+ """
310
+ return global_lock_wrapper(
311
+ pipeline.use_global_serial_port_lock,
312
+ lambda: ult80_communicate(chiller.check_arguments(command))
313
+ )
314
+
315
+
316
+ ###############################################################################
317
+ # workers
318
+ ###############################################################################
319
+
320
+
321
+ def sp_worker(pipeline, port, psus, channels):
322
+ """
323
+ Serial port worker for a single specific serial port.
324
+
325
+ This will not exit until it receives something from the broker.
326
+
327
+ psuset, lvpsu
328
+ configure_lvpsu(settings, pipeline, ser, dev)
329
+ order: reset, current_limit, output on/off, voltage
330
+ psuset, hvpsu
331
+ configure_hvpsu(settings, pipeline, ser, dev)
332
+ order: reset, current_limit
333
+
334
+ ---------------------------------------------------------------------------
335
+ args
336
+ pipeline : class Pipeline
337
+ port : string
338
+ e.g. '/dev/ttyUSB0'
339
+ psus : dict
340
+ Details of the PSU for this serial port (this dict will only have
341
+ a single entry)
342
+ channels : list of class Channel
343
+ details of the PSU channels for this serial port
344
+ ---------------------------------------------------------------------------
345
+ returns : none
346
+ ---------------------------------------------------------------------------
347
+ """
348
+ worker = mdwrkapi.MajorDomoWorker(
349
+ 'tcp://localhost:5556', bytes(port, encoding='utf-8'), pipeline.verbose
350
+ )
351
+
352
+ lut = {
353
+ 'psuset': handle_psuset_request,
354
+ 'psustat': handle_psustat_request,
355
+ 'ult80': handle_ult80_request,
356
+ }
357
+
358
+ reply = None
359
+ while True:
360
+ try:
361
+ pipeline.terminate[port].get_nowait()
362
+ except queue.Empty:
363
+ pass
364
+ else:
365
+ break
366
+
367
+ #######################################################################
368
+ # request : list
369
+ # e.g. [b'psuset -10 --limit 0.005 --on --channel 1 --serial 103807']
370
+ #######################################################################
371
+
372
+ request = worker.recv(reply)
373
+ if request is None:
374
+ break
375
+
376
+ #######################################################################
377
+ # process and generate reply
378
+ #######################################################################
379
+
380
+ command = next(r.decode() for r in request)
381
+ cli = command.split(' ')[0].strip()
382
+
383
+ t0 = time.monotonic()
384
+ try:
385
+ rval = lut[cli](pipeline, command, psus, channels)
386
+ except KeyError:
387
+ print('unknown command')
388
+ rval = None
389
+
390
+ reply = [
391
+ bytes(
392
+ f'{command} : {rval} ({time.monotonic() - t0:.6f} s)',
393
+ encoding='utf-8'
394
+ )
395
+ ]
396
+
397
+
398
+ ###############################################################################
399
+ # broker
400
+ ###############################################################################
401
+
402
+
403
+ def sp_broker(pipeline):
404
+ """
405
+ Serial port majordomo broker.
406
+
407
+ ---------------------------------------------------------------------------
408
+ args
409
+ pipeline : class Pipeline
410
+ ---------------------------------------------------------------------------
411
+ returns : none
412
+ ---------------------------------------------------------------------------
413
+ """
414
+ broker = mdbroker.MajorDomoBroker(pipeline.verbose)
415
+ try:
416
+ broker.bind('tcp://*:5556')
417
+ except zmq.error.ZMQError:
418
+ print('broker could not bind to port, server may already be running')
419
+ else:
420
+ broker.mediate()
421
+
422
+
423
+ ###############################################################################
424
+ # provision for conditional use of rs232 global lock
425
+ ###############################################################################
426
+
427
+
428
+ def global_lock_wrapper(wrap, func):
429
+ """
430
+ Conditionally apply the global serial port lock.
431
+
432
+ The lock is enabled by default and makes this server play nicely with
433
+ other command line scripts that access serial ports directly. However, this
434
+ prevents any exploitation of safe concurrency, and the mean response latency
435
+ may substantially increase depending on the access pattern.
436
+
437
+ ---------------------------------------------------------------------------
438
+ args
439
+ wrap : bool
440
+ func : callable
441
+ ---------------------------------------------------------------------------
442
+ returns
443
+ rval : returned result from func()
444
+ ---------------------------------------------------------------------------
445
+ """
446
+ rval = None
447
+
448
+ if wrap:
449
+ with open(common.RS232_LOCK_GLOBAL, 'a') as lock_file:
450
+
451
+ # acquire the RS232 global lock
452
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
453
+
454
+ rval = func()
455
+
456
+ # release the RS232 global lock
457
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
458
+ else:
459
+ rval = func()
460
+
461
+ return rval
462
+
463
+
464
+ ###############################################################################
465
+ # main
466
+ ###############################################################################
467
+
468
+ def main():
469
+ """
470
+ """
471
+ args = check_arguments()
472
+ settings = default()
473
+
474
+ ###########################################################################
475
+ # read hardware configuration from cache
476
+ #
477
+ # psus = {
478
+ # '/dev/ttyUSB6': (
479
+ # {
480
+ # 'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
481
+ # 'stopbits': 1,'xonxoff': False, 'dsrdtr': False,
482
+ # 'rtscts': False, 'timeout': 1, 'write_timeout': 1,
483
+ # 'inter_byte_timeout': None
484
+ # },
485
+ # 'chiller', None, 'ult80', 'thermoneslab',[''], 0.01
486
+ # ),
487
+ # ...
488
+ # }
489
+ #
490
+ # channels = [
491
+ # Channel(
492
+ # port="/dev/ttyUSB3",
493
+ # config={
494
+ # 'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
495
+ # 'stopbits': 1,
496
+ # 'xonxoff': False, 'dsrdtr': False, 'rtscts': True,
497
+ # 'timeout': 1, 'write_timeout': 1,
498
+ # 'inter_byte_timeout': None
499
+ # },
500
+ # serial_number="103807", model="hmp4040", manufacturer="hameg",
501
+ # channel="1", category="lvpsu", release_delay=0.026,
502
+ # ident="hmp4040 103807 1"
503
+ # ),
504
+ # ...
505
+ # ]
506
+ #
507
+ ###########################################################################
508
+
509
+ psus = common.cache_read(['hvpsu', 'lvpsu', 'chiller'])
510
+ channels = common.ports_to_channels(settings, psus)
511
+ ports = sorted(psus)
512
+
513
+ ###########################################################################
514
+ # set up hardware
515
+ ###########################################################################
516
+
517
+ class Pipeline:
518
+ terminate = {port: mp.Queue() for port in ports}
519
+ verbose = args.verbose
520
+ portaccess = {
521
+ port: threading.Lock()
522
+ for port in ports
523
+ }
524
+ use_global_serial_port_lock = not args.nolock
525
+
526
+ pipeline = Pipeline()
527
+
528
+ ###########################################################################
529
+ # Check serial ports as listed in the cache are reachable. For each port
530
+ # clear buffers and perform basic setup.
531
+ ###########################################################################
532
+
533
+ global_lock_wrapper(
534
+ pipeline.use_global_serial_port_lock,
535
+ lambda: common.initial_power_supply_check(
536
+ settings, pipeline, psus, channels, True, False,
537
+ )
538
+ )
539
+
540
+ ###########################################################################
541
+ # set up broker and one worker per serial port
542
+ ###########################################################################
543
+
544
+ workers = {}
545
+ for port in ports:
546
+
547
+ psu_for_port = {k: v for k, v in psus.items() if k == port}
548
+ channels_for_port = [c for c in channels if c.port == port]
549
+
550
+ workers[port] = threading.Thread(
551
+ target=sp_worker,
552
+ args=(
553
+ pipeline, port, psu_for_port, channels_for_port,
554
+ )
555
+ )
556
+
557
+ broker = threading.Thread(target=sp_broker, args=(pipeline, ), daemon=True)
558
+
559
+ ###########################################################################
560
+ # launch threads
561
+ ###########################################################################
562
+
563
+ broker.start()
564
+
565
+ for worker in workers.values():
566
+ worker.start()
567
+
568
+ ###########################################################################
569
+ # terminate threads
570
+ #
571
+ # Kill the worker threads one-by-one. The broker daemon thread will exit
572
+ # automatically when it's the only thing left running.
573
+ ###########################################################################
574
+
575
+ # for port, worker in workers.items():
576
+ # pipeline.terminate[port].put(None)
577
+ # worker.join()
578
+
579
+
580
+ ###############################################################################
581
+ if __name__ == '__main__':
582
+ main()