p3lib 1.1.108__py2.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.
p3lib/bokeh_gui.py ADDED
@@ -0,0 +1,845 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ import queue
5
+ import itertools
6
+ import threading
7
+ import asyncio
8
+ import socket
9
+ import os
10
+
11
+ from datetime import datetime
12
+ from functools import partial
13
+ from time import time
14
+
15
+ from p3lib.helper import getHomePath
16
+ from p3lib.bokeh_auth import SetBokehAuthAttrs
17
+
18
+ from bokeh.server.server import Server
19
+ from bokeh.application import Application
20
+ from bokeh.application.handlers.function import FunctionHandler
21
+ from bokeh.plotting import figure, ColumnDataSource
22
+ from bokeh.models import Range
23
+ from bokeh.palettes import Category20_20 as palette
24
+ from bokeh.resources import Resources
25
+ from bokeh.embed import file_html
26
+ from bokeh.server.auth_provider import AuthModule
27
+
28
+ from bokeh.plotting import save, output_file
29
+ from bokeh.layouts import gridplot, column, row
30
+ from bokeh.models.widgets import CheckboxGroup
31
+ from bokeh.models.widgets.buttons import Button
32
+ from bokeh.models.widgets import TextInput
33
+ from bokeh.models import Tabs
34
+ from bokeh.models import DataTable, TableColumn
35
+ from bokeh.models import CustomJS
36
+ from bokeh import events
37
+ from bokeh.models import TabPanel
38
+
39
+ class UpdateEvent(object):
40
+ """@brief Responsible for holding the state of an event sent from a non GUI thread
41
+ to the GUI thread context in order to update the GUI. The details of these
42
+ updates will be specific to the GUI implemented. Therefore this class should
43
+ be extended to include the events that are specific to the GUI implemented."""
44
+
45
+ UPDATE_STATUS_TEXT = 1 # This is an example of an event. It is intended to be used to
46
+ # update the status line in the GUI to provide the user with
47
+ # some feedback as to the current state of the GUI.
48
+
49
+ def __init__(self, id, argList=None):
50
+ """@brief Constructor
51
+ @param id An integer event ID
52
+ @param argList A list of arguments associated with the event"""
53
+ #As this is esentially a holding class we don't attempt to indicate provate attributes
54
+ self.id = id
55
+ self.argList = argList
56
+
57
+ class TimeSeriesPoint(object):
58
+ """@brief Resonsible for holding a time series point on a trace."""
59
+ def __init__(self, traceIndex, value, timeStamp=None):
60
+ """@brief Constructor
61
+ @param traceIndex The index of the trace this reading should be applied to.
62
+ The trace index starts at 0 for the top left plot (first
63
+ trace added) and increments with each call to addTrace()
64
+ on TimeSeriesPlotter instances.
65
+ @param value The Y value
66
+ @param timeStamp The x Value."""
67
+ self.traceIndex = traceIndex
68
+ if timeStamp:
69
+ self.time = timeStamp
70
+ else:
71
+ self.time = datetime.now()
72
+ self.value = value
73
+
74
+ class TabbedGUI(object):
75
+ """@brief A Generalised class responsible for plotting real time data."""
76
+
77
+ @staticmethod
78
+ def GetFigure(title=None, yAxisName=None, yRangeLimits=None, width=400, height=400):
79
+ """@brief A Factory method to obtain a figure instance.
80
+ A figure is a single plot area that can contain multiple traces.
81
+ @param title The title of the figure.
82
+ @param yAxisName The name of the Y axis.
83
+ @param yRangeLimits If None then the Y azxis will auto range.
84
+ If a list of two numerical values then this
85
+ defines the min and max Y axis range values.
86
+ @param width The width of the plot area in pixels.
87
+ @param height The height of the plot area in pixels.
88
+ @return A figure instance."""
89
+ if yRangeLimits and len(yRangeLimits) == 2:
90
+ yrange = Range(yRangeLimits[0], yRangeLimits[1])
91
+ fig = figure(title=title,
92
+ x_axis_type="datetime",
93
+ x_axis_location="below",
94
+ y_range=yrange,
95
+ width=width,
96
+ height=height)
97
+ else:
98
+ fig = figure(title=title,
99
+ x_axis_type="datetime",
100
+ x_axis_location="below",
101
+ width=width,
102
+ height=height)
103
+
104
+ fig.yaxis.axis_label = yAxisName
105
+ return fig
106
+
107
+ def __init__(self, docTitle, bokehPort=9090):
108
+ """@brief Constructor.
109
+ @param docTitle The document title.
110
+ @param bokehPort The port to run the server on."""
111
+ self._docTitle=docTitle
112
+ self._bokehPort=bokehPort
113
+ self._doc = None
114
+ self._tabList = []
115
+ self._server = None
116
+
117
+ def stopServer(self):
118
+ """@brief Stop the bokeh server"""
119
+ sys.exit()
120
+
121
+ def isServerRunning(self):
122
+ """@brief Check if the server is running.
123
+ @param True if the server is running. It may take some time (~ 20 seconds)
124
+ after the browser is closed before the server session shuts down."""
125
+ serverSessions = "not started"
126
+ if self._server:
127
+ serverSessions = self._server.get_sessions()
128
+
129
+ serverRunning = True
130
+ if not serverSessions:
131
+ serverRunning = False
132
+
133
+ return serverRunning
134
+
135
+ def runBokehServer(self):
136
+ """@brief Run the bokeh server. This is a blocking method."""
137
+ apps = {'/': Application(FunctionHandler(self.createPlot))}
138
+ self._server = Server(apps, port=self._bokehPort)
139
+ self._server.show("/")
140
+ self._server.run_until_shutdown()
141
+
142
+ def _run(self, method, args=[]):
143
+ """@brief Run a method in a separate thread. This is useful when
144
+ methods are called from gui events that take some time to execute.
145
+ For such methods the gui callback should call this method to execute
146
+ the time consuming methods in another thread.
147
+ @param method The method to execute.
148
+ @param A tuple of arguments to pass to the method.
149
+ If no arguments are required then an empty tuple should be passed."""
150
+ thread = threading.Thread(target=method, args=args)
151
+ thread.start()
152
+
153
+ def _sendUpdateEvent(self, updateEvent):
154
+ """@brief Send an event to the GUI context to update the GUI. When methods
155
+ are executing outside the gui thread but need to update the state
156
+ of the GUI, events must be sent to the gui context in order to update
157
+ the gui elements when they have the correct locks.
158
+ @param updateEvent An UpdateEvent instance."""
159
+ self._doc.add_next_tick_callback( partial(self._rxUpdateEvent, updateEvent) )
160
+
161
+ def _rxUpdateEvent(self, updateEvent):
162
+ """@brief Receive an event into the GUI context to update the GUI.
163
+ @param updateEvent An PSUGUIUpdateEvent instance. This method will
164
+ be specific to the GUI implemented and must therefore
165
+ be overridden in child classes."""
166
+ raise Exception("BUG: The _rxUpdateEvent() method must be implemented by classes that are children of the TabbedGUI class.")
167
+
168
+ class TimeSeriesPlotter(TabbedGUI):
169
+ """@brief Responsible for plotting data on tab 0 with no other tabs."""
170
+
171
+ def __init__(self, docTitle, bokehPort=9091, topCtrlPanel=True):
172
+ """@Constructor
173
+ @param docTitle The document title.
174
+ @param bokehPort The port to run the server on.
175
+ @param topCtrlPanel If True then a control panel is displayed at the top of the plot.
176
+ """
177
+ super().__init__(docTitle, bokehPort=bokehPort)
178
+ self._statusAreaInput = None
179
+ self._figTable=[[]]
180
+ self._grid = None
181
+ self._topCtrlPanel=topCtrlPanel
182
+ self._srcList = []
183
+ self._colors = itertools.cycle(palette)
184
+ self._queue = queue.Queue()
185
+ self._plottingEnabled = True
186
+
187
+ def addTrace(self, fig, legend_label, line_color=None, line_width=1):
188
+ """@brief Add a trace to a figure.
189
+ @param fig The figure to add the trace to.
190
+ @param line_color The line color
191
+ @param legend_label The text of the label.
192
+ @param line_width The trace line width."""
193
+ src = ColumnDataSource({'x': [], 'y': []})
194
+
195
+ #Allocate a line color if one is not defined
196
+ if not line_color:
197
+ line_color = next(self._colors)
198
+
199
+ if legend_label is not None and len(legend_label) > 0:
200
+ fig.line(source=src,
201
+ line_color = line_color,
202
+ legend_label = legend_label,
203
+ line_width = line_width)
204
+ else:
205
+ fig.line(source=src,
206
+ line_color = line_color,
207
+ line_width = line_width)
208
+ self._srcList.append(src)
209
+
210
+ def _update(self):
211
+ """@brief called periodically to update the plot traces."""
212
+ if self._plottingEnabled:
213
+ while not self._queue.empty():
214
+ timeSeriesPoint = self._queue.get()
215
+ new = {'x': [timeSeriesPoint.time],
216
+ 'y': [timeSeriesPoint.value]}
217
+ source = self._srcList[timeSeriesPoint.traceIndex]
218
+ source.stream(new)
219
+
220
+ def addValue(self, traceIndex, value, timeStamp=None):
221
+ """@brief Add a value to be plotted. This adds to queue of values
222
+ to be plotted the next time _update() is called.
223
+ @param traceIndex The index of the trace this reading should be applied to.
224
+ @param value The Y value to be plotted.
225
+ @param timeStamp The timestamp associated with the value. If not supplied
226
+ then the timestamp will be created at the time when This
227
+ method is called."""
228
+ timeSeriesPoint = TimeSeriesPoint(traceIndex, value, timeStamp=timeStamp)
229
+ self._queue.put(timeSeriesPoint)
230
+
231
+ def addRow(self):
232
+ """@brief Add an empty row to the figures."""
233
+ self._figTable.append([])
234
+
235
+ def addToRow(self, fig):
236
+ """@brief Add a figure to the end of the current row of figues.
237
+ @param fig The figure to add."""
238
+ self._figTable[-1].append(fig)
239
+
240
+ def createPlot(self, doc, ):
241
+ """@brief create a plot figure.
242
+ @param doc The document to add the plot to."""
243
+ self._doc = doc
244
+ self._doc.title = self._docTitle
245
+
246
+ plotPanel = self._getPlotPanel()
247
+
248
+ self._tabList.append( TabPanel(child=plotPanel, title="Plots") )
249
+ self._doc.add_root( Tabs(tabs=self._tabList) )
250
+ self._doc.add_periodic_callback(self._update, 100)
251
+
252
+ def _getPlotPanel(self):
253
+ """@brief Add tab that shows plot data updates."""
254
+ self._grid = gridplot(children = self._figTable, toolbar_location='left')
255
+
256
+ if self._topCtrlPanel:
257
+ checkbox1 = CheckboxGroup(labels=["Plot Data"], active=[0, 1],max_width=70)
258
+ checkbox1.on_change('active', self._checkboxHandler)
259
+
260
+ self._fileToSave = TextInput(title="File to save", max_width=150)
261
+
262
+ saveButton = Button(label="Save", button_type="success", width=50)
263
+ saveButton.on_click(self._savePlot)
264
+
265
+ shutDownButton = Button(label="Quit", button_type="success", width=50)
266
+ shutDownButton.on_click(self.stopServer)
267
+
268
+ self._statusBarWrapper = StatusBarWrapper()
269
+
270
+ plotRowCtrl = row(children=[checkbox1, saveButton, self._fileToSave, shutDownButton])
271
+ plotPanel = column([plotRowCtrl, self._grid, self._statusBarWrapper.getWidget()])
272
+ else:
273
+ plotPanel = column([self._grid])
274
+
275
+ return plotPanel
276
+
277
+ def _savePlot(self):
278
+ """@brief Save plot to a single html file. This allows the plots to be
279
+ analysed later."""
280
+ if self._fileToSave and self._fileToSave.value:
281
+ if self._fileToSave.value.endswith(".html"):
282
+ filename = self._fileToSave.value
283
+ else:
284
+ filename = self._fileToSave.value + ".html"
285
+ output_file(filename)
286
+ # Save all the plots in the grid to an html file that allows
287
+ # display in a browser and plot manipulation.
288
+ save( self._grid )
289
+ self._statusBarWrapper.setStatus("Saved {}".format(filename))
290
+
291
+ def _checkboxHandler(self, attr, old, new):
292
+ """@brief Called when the checkbox is clicked."""
293
+ if 0 in list(new): # Is first checkbox selected
294
+ self._plottingEnabled = True
295
+ self._statusBarWrapper.setStatus("Plotting enabled")
296
+ else:
297
+ self._plottingEnabled = False
298
+ self._statusBarWrapper.setStatus("Plotting disabled")
299
+
300
+ def runNonBlockingBokehServer(self):
301
+ """@brief Run the bokeh server in a separate thread. This is useful
302
+ if the we want to load realtime data into the plot from the
303
+ main thread."""
304
+ self._serverThread = threading.Thread(target=self._runBokehServer)
305
+ self._serverThread.setDaemon(True)
306
+ self._serverThread.start()
307
+
308
+ def _runBokehServer(self):
309
+ """@brief Run the bokeh server. This is called when the bokeh server is executed in a thread."""
310
+ apps = {'/': Application(FunctionHandler(self.createPlot))}
311
+ #As this gets run in a thread we need to start an event loop
312
+ evtLoop = asyncio.new_event_loop()
313
+ asyncio.set_event_loop(evtLoop)
314
+ self._server = Server(apps, port=self._bokehPort)
315
+ self._server.start()
316
+ #Show the server in a web browser window
317
+ self._server.io_loop.add_callback(self._server.show, "/")
318
+ self._server.io_loop.start()
319
+
320
+ class StatusBarWrapper(object):
321
+ """@brief Responsible for presenting a single status line of text in a GUI
322
+ that runs the width of the page (normally at the bottom).
323
+ @param sizing_mode The widget sizing mode. By default the status bar will streach accross the width of the layout.
324
+ @param height The height of the status bar in pixels. Default 50 gives good L&F on most browsers."""
325
+ def __init__(self, sizing_mode="stretch_width", height=50):
326
+ data = dict(
327
+ status = [],
328
+ )
329
+ self.source = ColumnDataSource(data)
330
+
331
+ columns = [
332
+ TableColumn(field="status", title="Status"),
333
+ ]
334
+ self.statusBar = DataTable(source=self.source,
335
+ columns=columns,
336
+ header_row=True,
337
+ index_position=None,
338
+ sizing_mode=sizing_mode,
339
+ height=height)
340
+
341
+ def getWidget(self):
342
+ """@brief return an instance of the status bar widget to be added to a layout."""
343
+ return self.statusBar
344
+
345
+ def setStatus(self, msg):
346
+ """@brief Set the message iun the status bar.
347
+ @param The message to be displayed."""
348
+ self.source.data = {"status": [msg]}
349
+
350
+ class ReadOnlyTableWrapper(object):
351
+ """@brief Responsible for presenting a table of values that can be updated dynamically."""
352
+ def __init__(self, columnNameList, height=400, heightPolicy="auto", showLastRows=0, index_position=None):
353
+ """@brief Constructor
354
+ @param columnNameList A List of strings denoting each column in the 2 dimensional table.
355
+ @param height The hieght of the table viewport in pixels.
356
+ @param heightPolicy The height policy (auto, fixed, fit, min, max). default=fixed.
357
+ @param showLastRows The number of rows to show in the table. If set to 2 then only
358
+ the last two rows in the table are displayed but they ate scrolled into view.
359
+ The default=0 which will display all rows and will not scroll the latest
360
+ into view..
361
+ @param index_position The position of the index column in the table. 0 = the first
362
+ column. Default is None which does not display the index column."""
363
+ self._columnNameList = columnNameList
364
+ self._dataDict = {}
365
+ self._columns = []
366
+ for columnName in columnNameList:
367
+ self._dataDict[columnName]=[]
368
+ self._columns.append( TableColumn(field=columnName, title=columnName) )
369
+
370
+ self._source = ColumnDataSource(self._dataDict)
371
+
372
+ self._dataTable = DataTable(source=self._source, columns=self._columns, height=height, height_policy=heightPolicy, frozen_rows=-showLastRows, index_position=index_position)
373
+
374
+ def getWidget(self):
375
+ """@brief Return an instance of the DataTable widget to be added to a layout."""
376
+ return self._dataTable
377
+
378
+ def setRows(self, rowList):
379
+ """@brief Set the rows in the table.
380
+ @param rowList A list of rows of data. Each row must contain a list of values for each column in the table."""
381
+ for _row in rowList:
382
+ if len(_row) != len(self._columnNameList):
383
+ raise Exception("{} row should have {} values.".format(_row, len(self._columnNameList)))
384
+ dataDict = {}
385
+ colIndex = 0
386
+ for columnName in self._columnNameList:
387
+ valueList = []
388
+ for _row in rowList:
389
+ valueList.append( _row[colIndex] )
390
+ dataDict[columnName]=valueList
391
+
392
+ colIndex = colIndex + 1
393
+ self._source.data = dataDict
394
+
395
+ def appendRow(self, _row):
396
+ """@brief Set the rows in the table.
397
+ @param rowList A list of rows of data. Each row must contain a list of values for each column in the table."""
398
+ dataDict = {}
399
+ colIndex = 0
400
+ for columnName in self._columnNameList:
401
+ valueList = [_row[colIndex]]
402
+ dataDict[columnName]=valueList
403
+ colIndex = colIndex + 1
404
+ self._source.stream(dataDict)
405
+
406
+ class AlertButtonWrapper(object):
407
+ """@brief Responsible for presenting a button that when clicked displayed an alert dialog."""
408
+ def __init__(self, buttonLabel, alertMessage, buttonType="default", onClickMethod=None):
409
+ """@brief Constructor
410
+ @param buttonLabel The text displayed on the button.
411
+ @param alertMessage The message displayed in the alert dialog when clicked.
412
+ @param buttonType The type of button to display (default, primary, success, warning, danger, light)).
413
+ @param onClickMethod An optional method that is called when the alert OK button has been clicked.
414
+ """
415
+ self._button = Button(label=buttonLabel, button_type=buttonType)
416
+ if onClickMethod:
417
+ self.addOnClickMethod(onClickMethod)
418
+
419
+ source = {"msg": alertMessage}
420
+ callback1 = CustomJS(args=dict(source=source), code="""
421
+ var msg = source['msg']
422
+ alert(msg);
423
+ """)
424
+ self._button.js_on_event(events.ButtonClick, callback1)
425
+
426
+ def addOnClickMethod(self, onClickMethod):
427
+ """@brief Add a method that is called after the alert dialog has been displayed.
428
+ @param onClickMethod The method that is called."""
429
+ self._button.on_click(onClickMethod)
430
+
431
+ def getWidget(self):
432
+ """@brief return an instance of the button widget to be added to a layout."""
433
+ return self._button
434
+
435
+ class ShutdownButtonWrapper(object):
436
+ """@brief Responsible for presenting a shutdown button. When the button is clicked
437
+ an alert message is displayed instructing the user to close the browser
438
+ window. When the OK button in the alert dialog is clicked the
439
+ application is shutdown."""
440
+ def __init__(self, shutDownMethod):
441
+ """@brief Constructor
442
+ @param shutDownMethod The method that is called to shutdown the application.
443
+ """
444
+ self._alertButtonWrapper = AlertButtonWrapper("Quit",\
445
+ "The application is shutting down. Please close the browser window",\
446
+ buttonType="danger",\
447
+ onClickMethod=shutDownMethod)
448
+
449
+ def getWidget(self):
450
+ """@brief return an instance of the shutdown button widget to be added to a layout."""
451
+ return self._alertButtonWrapper.getWidget()
452
+
453
+ class SingleAppServer(object):
454
+ """@brief Responsible for running a bokeh server containing a single app.
455
+ The server may be started by calling either a blocking or a non
456
+ blocking method. This provides a basic parennt class with
457
+ the freedom to define your app as required."""
458
+
459
+ @staticmethod
460
+ def GetNextUnusedPort(basePort=1024, maxPort = 65534, bindAddress="0.0.0.0"):
461
+ """@brief Get the first unused above the base port.
462
+ @param basePort The port to start checking for available ports.
463
+ @param maxPort The highest port number to check.
464
+ @param bindAddress The address to bind to.
465
+ @return The TCP port or -1 if no port is available."""
466
+ port = basePort
467
+ while True:
468
+ try:
469
+ sock = socket.socket()
470
+ sock.bind((bindAddress, port))
471
+ sock.close()
472
+ break
473
+ except:
474
+ port = port + 1
475
+ if port > maxPort:
476
+ port = -1
477
+ break
478
+
479
+ return port
480
+
481
+ def __init__(self, bokehPort=0):
482
+ """@Constructor
483
+ @param bokehPort The TCP port to run the server on. If left at the default
484
+ of 0 then a spare TCP port will be used.
485
+ """
486
+ if bokehPort == 0:
487
+ bokehPort = SingleAppServer.GetNextUnusedPort()
488
+ self._bokehPort=bokehPort
489
+
490
+ def getServerPort(self):
491
+ """@return The bokeh server port."""
492
+ return self._bokehPort
493
+
494
+ def runBlockingBokehServer(self, appMethod=None):
495
+ """@brief Run the bokeh server. This method will only return when the server shuts down.
496
+ @param appMethod The method called to create the app."""
497
+ if appMethod is None:
498
+ appMethod = self.app
499
+ apps = {'/': Application(FunctionHandler(appMethod))}
500
+ #As this gets run in a thread we need to start an event loop
501
+ evtLoop = asyncio.new_event_loop()
502
+ asyncio.set_event_loop(evtLoop)
503
+ self._server = Server(apps, port=self._bokehPort)
504
+ self._server.start()
505
+ #Show the server in a web browser window
506
+ self._server.io_loop.add_callback(self._server.show, "/")
507
+ self._server.io_loop.start()
508
+
509
+ def runNonBlockingBokehServer(self, appMethod=None):
510
+ """@brief Run the bokeh server in a separate thread. This is useful
511
+ if the we want to load realtime data into the plot from the
512
+ main thread.
513
+ @param appMethod The method called to create the app."""
514
+ if appMethod is None:
515
+ appMethod = self.app
516
+ self._serverThread = threading.Thread(target=self.runBlockingBokehServer, args=(appMethod,))
517
+ self._serverThread.setDaemon(True)
518
+ self._serverThread.start()
519
+
520
+ def app(self, doc):
521
+ """@brief Start the process of creating an app.
522
+ @param doc The document to add the plot to."""
523
+ raise NotImplementedError("app() method not implemented by {}".format(self.__class__.__name__))
524
+
525
+ class GUIModel_A(SingleAppServer):
526
+ """@brief This class is responsible for providing a mechanism for creating a GUI as
527
+ simply as possible with some common features that can be updated dynamically.
528
+
529
+ These common features are currently.
530
+ 1 - A widget at the bottom of the page for saving the state of the page to an
531
+ HTML file. This is useful when saving the states of plots as the HTML files
532
+ can be distributed and when recieved opened by users with their web browser.
533
+ When opened the plots can be manipulated (zoom etc).
534
+ 2 - A status bar at the bottom of the page."""
535
+
536
+ UPDATE_POLL_MSECS = 100
537
+ BOKEH_THEME_CALIBER = "caliber"
538
+ BOKEH_THEME_DARK_MINIMAL = "dark_minimal"
539
+ BOKEH_THEME_LIGHT_MINIMAL = "light_minimal"
540
+ BOKEH_THEME_NIGHT_SKY = "night_sky"
541
+ BOKEH_THEME_CONTRAST = "contrast"
542
+ BOKEH_THEME_NAMES = (BOKEH_THEME_CALIBER,
543
+ BOKEH_THEME_DARK_MINIMAL,
544
+ BOKEH_THEME_DARK_MINIMAL,
545
+ BOKEH_THEME_NIGHT_SKY,
546
+ BOKEH_THEME_CONTRAST)
547
+ DEFAULT_BOKEH_THEME = BOKEH_THEME_NAMES[0]
548
+
549
+ def __init__(self, docTitle,
550
+ bokehServerPort=SingleAppServer.GetNextUnusedPort(),
551
+ includeSaveHTML=True,
552
+ theme=DEFAULT_BOKEH_THEME,
553
+ updatePollPeriod=UPDATE_POLL_MSECS):
554
+ """@Constructor.
555
+ @param docTitle The title of the HTML doc page.
556
+ @param includeSaveHTML If True include widgets at the bottom of the web page for saving it as an HTML file.
557
+ @param theme The theme that defines the colours used by the GUI (default=caliber). BOKEH_THEME_NAMES defines the
558
+ available themes.
559
+ @param updatePollPeriod The GUI update poll period in milli seconds.
560
+ @param bokehServerPort The TCP port to bind the server to."""
561
+ super().__init__(bokehPort=bokehServerPort)
562
+ self._docTitle = docTitle # The HTML page title shown in the browser window.
563
+ self._includeSaveHTML = includeSaveHTML # If True then the save HTML page is displayed at the bottom of the web page.
564
+ self._theme = theme # The theme that defines the colors used by the GUI.
565
+ self._updatePollPeriod = updatePollPeriod # The GUI poll time in milli seconds.
566
+ self._appInitComplete = False # True when the app is initialised
567
+ self._lastUpdateTime = time() # A timer used to show the user the status of the data plot as it can take a while for bokeh to render it on the server.
568
+ self._plotDataQueue = queue.Queue() # The queue holding rows of data to be plotted and other messages passed into the GUI thread inside _update() method.
569
+ self._guiTable = [] # A two dimensional table that holds the GUI components in a grid.
570
+ self._uio = None # A UIO instance for displaying update messages on stdout. If left at None then no messages are displayed.
571
+ self._updateStatus = True # Flag to indicate if the status bar should be updated.
572
+ self._htmlFileName = "plot.html" # The default HTML file to save.
573
+
574
+ def send(self, theDict):
575
+ """@brief Send a dict to the GUI. This method must be used to send data to the GUI
576
+ thread as all actions that change GUI components must be performed inside
577
+ the GUI thread. The dict passed here is the same dict that gets passed
578
+ to the self._processDict(). This allows subclasses to receive these
579
+ dicts and update the GUI based on the contents.
580
+ @param theDict A Dict containing data to update the GUI."""
581
+ self._plotDataQueue.put(theDict)
582
+
583
+ def setUIO(self, uio):
584
+ """@brief Set a UIO instance to display info and debug messages on std out."""
585
+ self._uio = uio
586
+
587
+ def setHTMLSaveFileName(self, defaultHTMLFileName):
588
+ """@brief Set the HTML filename to save.
589
+ @param defaultHTMLFileName The default HTML filename to save."""
590
+ self._htmlFileName = defaultHTMLFileName
591
+
592
+ def app(self, doc):
593
+ """@brief Start the process of creating an app.
594
+ @param doc The document to add the plot to."""
595
+ self._doc = doc
596
+ self._doc.title = self._docTitle
597
+ self._doc.theme = self._theme
598
+ # The status bar can be added to the bottom of the window showing status information.
599
+ self._statusBar = StatusBarWrapper()
600
+ #Setup the callback through which all updates to the GUI will be performed.
601
+ self._doc.add_periodic_callback(self._update, self._updatePollPeriod)
602
+
603
+ def _info(self, msg):
604
+ """@brief Display an info level message on the UIO instance if we have one."""
605
+ if self._uio:
606
+ self._uio.info(msg)
607
+
608
+ def _debug(self, msg):
609
+ """@brief Display a debug level message on the UIO instance if we have one."""
610
+ if self._uio:
611
+ self._uio.debug(msg)
612
+
613
+ def _internalInitGUI(self):
614
+ """@brief Perform the GUI initalisation if not already performed."""
615
+ if not self._appInitComplete:
616
+ self._initGUI()
617
+ if self._includeSaveHTML:
618
+ self._addHTMLSaveWidgets()
619
+ self._guiTable.append([self._statusBar.getWidget()])
620
+ gp = gridplot( children = self._guiTable, toolbar_location="above", merge_tools=True)
621
+ self._doc.add_root( gp )
622
+ self._appInitComplete = True
623
+
624
+ def _initGUI(self):
625
+ """@brief Setup the GUI before the save HTML controls and status bar are added.
626
+ This should be implemented in a subclass to setup the GUI before it's updated.
627
+ The subclass must add GUI components/widgets to self._guiTable as this is added
628
+ to a gridplot before adding to the root pane."""
629
+ raise NotImplementedError("_initGUI() method not implemented by {}".format(self.__class__.__name__))
630
+
631
+ def _processDict(self, theDict):
632
+ """@brief Process a dict received from the self._plotDataQueue inside the GUI thread.
633
+ This should be implemented in a subclass to update the GUI. Typically to add
634
+ data to a plot in realtime. """
635
+ raise NotImplementedError("_processDict() method not implemented by {}".format(self.__class__.__name__))
636
+
637
+ def _update(self, maxBlockSecs=1):
638
+ """@brief called periodically to update the plot traces.
639
+ @param maxBlockSecs The maximum time before this method returns."""
640
+ callTime = time()
641
+
642
+ self._internalInitGUI()
643
+
644
+ self._showStats()
645
+
646
+ #While we have data to process
647
+ while not self._plotDataQueue.empty():
648
+
649
+ #Don't block the bokeh thread for to long while we process dicts from the queue or it will crash.
650
+ if time() > callTime+maxBlockSecs:
651
+ self._debug("Exit _update() with {} outstanding messages after {:.1f} seconds.".format( self._plotDataQueue.qsize(), time()-callTime ))
652
+ break
653
+
654
+ objReceived = self._plotDataQueue.get()
655
+
656
+ if isinstance(objReceived, dict):
657
+ self._processDict(objReceived)
658
+
659
+ #If we have left some actions unprocessed we'll handle them next time we get called.
660
+ if self._plotDataQueue.qsize() > 0:
661
+ self._setStatus( "Outstanding GUI updates: {}".format(self._plotDataQueue.qsize()) )
662
+ self._updateStatus = True
663
+
664
+ elif self._updateStatus:
665
+ self._setStatus( "Outstanding GUI updates: 0" )
666
+ self._updateStatus = False
667
+
668
+ def _addHTMLSaveWidgets(self):
669
+ """@brief Add the HTML save field and button to the bottom of the GUI."""
670
+ saveButton = Button(label="Save HTML File", button_type="success", width=50)
671
+ saveButton.on_click(self._savePlot)
672
+
673
+ self.fileToSave = TextInput(title="HTML file to save")
674
+
675
+ self.fileToSave.value = os.path.join(getHomePath(), self._htmlFileName)
676
+
677
+ self._guiTable.append([self.fileToSave])
678
+ self._guiTable.append([saveButton])
679
+
680
+ def _savePlot(self):
681
+ """@brief Save an html file with the current GUI state."""
682
+ try:
683
+ if len(self.fileToSave.value) > 0:
684
+ fileBasename = os.path.basename(self.fileToSave.value)
685
+ filePath = self.fileToSave.value.replace(fileBasename, "")
686
+ if os.path.isdir(filePath):
687
+ if os.access(filePath, os.W_OK):
688
+ msg = "Saving {}. Please wait...".format(self.fileToSave.value)
689
+ self._info(msg)
690
+ self._setStatus(msg)
691
+ # Static appears in the web browser tab to indicate a static HTML file to the user.
692
+ self.save(self.fileToSave.value, title="Static: {}".format(self._doc.title))
693
+ self._setStatus( "Saved {}".format(self.fileToSave.value) )
694
+ else:
695
+ self._setStatus( "{} exists but no write access.".format(filePath) )
696
+ else:
697
+ self._setStatus( "{} path not found.".format(filePath) )
698
+ else:
699
+ self._statusBar.setStatus("Please enter the html file to save.")
700
+ except Exception as ex:
701
+ self._statusBar.setStatus( str(ex) )
702
+
703
+ def save(self, filename, title=None, theme=None):
704
+ """@brief Save the state of the document to an HTML file.
705
+ This was written to allow the suppression of Javascript warnings
706
+ as previously the bokeh save method was called.
707
+ @param filename The filename to save.
708
+ @param title The document title. If left as Non then the title of the current page is used.
709
+ @param theme The bokeh theme. If left as None then the theme used by the current page is used."""
710
+ if theme is None:
711
+ theme = self._doc.theme
712
+
713
+ if title is None:
714
+ title = self._doc.title
715
+
716
+ html = file_html(self._doc,
717
+ Resources(mode=None),
718
+ title=title,
719
+ template=None,
720
+ theme=theme,
721
+ suppress_callback_warning=True)
722
+
723
+ with open(filename, mode="w", encoding="utf-8") as f:
724
+ f.write(html)
725
+
726
+ def _setStatus(self, msg):
727
+ """@brief Set the display message in the status bar at the bottom of the GUI.
728
+ @param msg The message to be displayed."""
729
+ self._statusMsg = msg
730
+ self._statusBar.setStatus(self._statusMsg)
731
+
732
+ def _showStats(self):
733
+ """@brief Show the current outstanding message count so that user user is aware if the GUI
734
+ is taking some time to update."""
735
+ if time() > self._lastUpdateTime+10:
736
+ self._debug("Updating GUI: Outstanding messages = {}".format( self._plotDataQueue.qsize()) )
737
+ self._lastUpdateTime = time()
738
+
739
+
740
+ class MultiAppServer(object):
741
+ """@brief Responsible for running a bokeh server containing a multiple apps.
742
+ The server may be started by calling either a blocking or a non
743
+ blocking method. This provides a basic parent class with
744
+ the freedom to define your app as required."""
745
+
746
+ BOKEH_ALLOW_WS_ORIGIN = 'BOKEH_ALLOW_WS_ORIGIN'
747
+
748
+ @staticmethod
749
+ def GetNextUnusedPort(basePort=1024, maxPort = 65534, bindAddress="0.0.0.0"):
750
+ """@brief A helper method to get the first unused above the base port.
751
+ @param basePort The port to start checking for available ports.
752
+ @param maxPort The highest port number to check.
753
+ @param bindAddress The address to bind to.
754
+ @return The TCP port or -1 if no port is available."""
755
+ return SingleAppServer.GetNextUnusedPort(basePort=basePort, maxPort=maxPort, bindAddress=bindAddress)
756
+
757
+ def __init__(self,
758
+ address="0.0.0.0",
759
+ bokehPort=0,
760
+ wsOrigin="*:*",
761
+ credentialsJsonFile=None,
762
+ loginHTMLFile="login.html",
763
+ accessLogFile=None):
764
+ """@Constructor
765
+ @param address The address of the bokeh server.
766
+ @param bokehPort The TCP port to run the server on. If left at the default
767
+ of 0 then a spare TCP port will be used.
768
+ @param credentialsJsonFile A file that contains the json formatted hashed (via argon2) login credentials.
769
+ @param accessLogFile The log file to record access to. If left as None then no logging occurs.
770
+ """
771
+ if bokehPort == 0:
772
+ bokehPort = MultiAppServer.GetNextUnusedPort()
773
+ self._bokehPort=bokehPort
774
+ self._address=address
775
+ os.environ[MultiAppServer.BOKEH_ALLOW_WS_ORIGIN]=wsOrigin
776
+ self._credentialsJsonFile = credentialsJsonFile
777
+ self._loginHTMLFile = loginHTMLFile
778
+ self._accessLogFile = accessLogFile
779
+
780
+ def getServerPort(self):
781
+ """@return The bokeh server port."""
782
+ return self._bokehPort
783
+
784
+ def _getAppDict(self, appMethodDict):
785
+ """@brief Get a dict that can be passed to the Server object to
786
+ define the apps to be served."""
787
+ appDict = {}
788
+ for key in appMethodDict:
789
+ appMethod = appMethodDict[key]
790
+ appDict[key]=Application(FunctionHandler(appMethod))
791
+ return appDict
792
+
793
+ def runBlockingBokehServer(self, appMethodDict, openBrowser=True):
794
+ """@brief Run the bokeh server. This method will only return when the server shuts down.
795
+ @param appMethodDict This dict holds references to all the apps yourwish the server
796
+ to run.
797
+ The key to each dict entry should be the last part of the URL to point to the app.
798
+ E.G '/' is the root app which is displayed when the full URL is given.
799
+ The value should be a reference to the method on this object that holds
800
+ the app code.
801
+ @param openBrowser If True then open a browser connected to the / app (default=True)."""
802
+ appDict = self._getAppDict(appMethodDict)
803
+
804
+ #As this gets run in a thread we need to start an event loop
805
+ evtLoop = asyncio.new_event_loop()
806
+ asyncio.set_event_loop(evtLoop)
807
+ if self._credentialsJsonFile:
808
+ SetBokehAuthAttrs(self._credentialsJsonFile,
809
+ self._loginHTMLFile,
810
+ accessLogFile=self._accessLogFile)
811
+ # We don't check the credentials hash file exists as this should have been
812
+ # done at a higher level. We assume that web server authoristion is required.
813
+ selfPath = os.path.dirname(os.path.abspath(__file__))
814
+ authFile = os.path.join(selfPath, "bokeh_auth.py")
815
+ authModule = AuthModule(authFile)
816
+ self._server = Server(appDict,
817
+ address=self._address,
818
+ port=self._bokehPort,
819
+ auth_provider=authModule)
820
+
821
+ else:
822
+ self._server = Server(appDict,
823
+ address=self._address,
824
+ port=self._bokehPort)
825
+
826
+ self._server.start()
827
+ if openBrowser:
828
+ #Show the server in a web browser window
829
+ self._server.io_loop.add_callback(self._server.show, "/")
830
+ self._server.io_loop.start()
831
+
832
+ def runNonBlockingBokehServer(self, appMethodDict, openBrowser=True):
833
+ """@brief Run the bokeh server in a separate thread. This is useful
834
+ if the we want to load realtime data into the plot from the
835
+ main thread.
836
+ @param @param appMethodDict This dict holds references to all the apps yourwish the server
837
+ to run.
838
+ The key to each dict entry should be the last part of the URL to point to the app.
839
+ E.G '/' is the root app which is displayed when the full URL is given.
840
+ The value should be a reference to the method on this object that holds
841
+ the app code.
842
+ @param openBrowser If True then open a browser connected to the / app (default=True)"""
843
+ self._serverThread = threading.Thread(target=self.runBlockingBokehServer, args=(appMethodDict,openBrowser))
844
+ self._serverThread.setDaemon(True)
845
+ self._serverThread.start()