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/__init__.py +0 -0
- p3lib/ate.py +108 -0
- p3lib/bokeh_auth.py +363 -0
- p3lib/bokeh_gui.py +845 -0
- p3lib/boot_manager.py +420 -0
- p3lib/conduit.py +145 -0
- p3lib/database_if.py +289 -0
- p3lib/file_io.py +154 -0
- p3lib/gnome_desktop_app.py +146 -0
- p3lib/helper.py +420 -0
- p3lib/json_networking.py +239 -0
- p3lib/login.html +98 -0
- p3lib/mqtt_rpc.py +240 -0
- p3lib/netif.py +226 -0
- p3lib/netplotly.py +223 -0
- p3lib/ngt.py +841 -0
- p3lib/pconfig.py +874 -0
- p3lib/ssh.py +935 -0
- p3lib/table_plot.py +675 -0
- p3lib/uio.py +574 -0
- p3lib-1.1.108.dist-info/LICENSE +21 -0
- p3lib-1.1.108.dist-info/METADATA +34 -0
- p3lib-1.1.108.dist-info/RECORD +24 -0
- p3lib-1.1.108.dist-info/WHEEL +4 -0
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()
|