A guide to making changes and finding values, from UI to API call.
So, you have the sudden need to dive into the Chronos back-of-camera user interface. Perhaps someone hired you, or you're part of a research project, and now you need to change how the camera operates. This document will introduce you to the coding conventions used to write this GUI, and tell you how to find variables and callbacks.
Program execution starts in chronosGui2/main.py
. chronosGui2/main.py
instantiates all the screens, defined in the chronosGui2/screens/
folder.
Each screen consists of .ui
for layout and a .py
file for logic. To find out what a button does, open the screen .ui
file in Qt Designer or Qt Creator. Find the widget name, and search for that name in the corresponding .py
file.
Callbacks in the .py
files use the D-Bus API, mostly via control().set({"key": value})
and api.observe("key", callback)
, to make things happen.
If you need help or would just like some advice, make a posting on Krontalk.
The UI is started as a service, defined in [TODO DDR 2018-11-22: write the service and note it here], or by starting it in developer mode by logging into the camera and running:
cd gui
util/watch-camera.sh
The first file that runs, the program entry point, is ~chronosGui2/main.py
. (~
indicates project root, here.) Starting from the top of the file, we have a few imports and a bit of setup code. The first class we encounter is the Window
class. This class handles the window-level functions of the UI, which is mostly just switching between screens. This class is where you would register a new screen, by importing it and registering it in _availableScreens
. The screens are switched by the show()
and the back()
methods. For example, on the main screen (defined in ~chronosGui2/screens/main.py
, as separate from ~chronosGui2/main.py
) we have the following code to show the power screen when we click on the power button:
self.uiBattery.clicked.connect(lambda: window.show('power'))
By default, the 'main'
screen is loaded when the camera is started.
The rest of the code in ~chronosGui2/main.py
sets up app input events, and finally starts the app itself running. The QT main loop is the last thing started, by app.exec_()
. It takes flow control away from our script. The rest of our code will only be ran when a callback is invoked by the QT framework.
When the Window
class loads a screen, its __init__
function is called. This function is where UI objects, as defined in the .py
file's corresponding .ui
file, are linked to the logic in the .py
file. So, as an example, let's say we want to add a button to the power screen that takes you to the file save settings screen. Perhaps we want to save our video differently if the battery is getting low. First, we will open the ~chronosGui2/screens/power.py
file in our favourite text editor, and open ~chronosGui2/screens/power.ui
in Qt Designer or Qt Creator. (We must launch these programs using the scripts in the ~util/
folder, since some environment variables need to be set for them to work.)
In either Designer or Creator, when we open power.ui
, we should see something like the following:
Note that, unlike on the camera, there is a bit of colourful debug information showing the hit margin of the interactive elements. If you don't see this, then the Chronos plugins have not loaded correctly and you will not be able to edit the files successfully.
Now, let's add a button beside the Done button. We'll scroll down in the widget box to the left, to Chronos section, and place one of our Buttons. (This is separate from the Push Button under the Buttons section of the widgets box.) We'll drag the button onto the screen, and set the text on it to "File Settings". If we want to set a stylesheet on it, we'll have to scroll down in the property editor to customStyleSheet. The default stylesheet is already in use for the click margins on the button. For example, setting our button's customStyleSheet to Button { background: lightgreen }
unsurprisingly results in a light green button.
To wire our button in, let's look at how the Done button works. Clicking on the button, we see it is named uiDone
in the object inspector panel. Following that convention, let's name our Save Setting button uiSaveSettings. We prepend ui
to our elements' names because—back in power.py
—the elements are loaded directly in to our Power class. We don't want to worry about conflicting with an existing class member when we're editing our .ui files.
In power.py
, let's see how the Done button changes screens. Searching for uiDone
, our Done button's name, we find self.uiDone.clicked.connect(window.back)
. This connects the clicked
signal to the back
function. The clicked
signal is provided by Qt, and the back
function comes from the Window class defined in ~chronosGui2/main.py
.
Looking through the window class, either in the code or by using the dbg()
call to drop into an interactive debugger, we find a likely-looking function called show()
. We'll use this function to show the File Settings window when our new button is clicked. Above the Done button's clicked handler, we'll write the following:
self.uiSaveSettings.clicked.connect(
lambda: window.show('file_settings') )
If we wanted to have a more complex function as a click handler, we'd define a class function in the Battery class. For example, the uiSafelyPowerDown
checkbox is bound to self.uiChart.update
, so when we check or uncheck it the chart updates with the line showing the power-off threshold.
To control the camera, we will use the internal D-Bus API. (Refer to the D-Bus API documentation for details.) Let's look at the charge-remaining dropdown on our power screen, still in ~chronosGui2/screens/power.py
. Clicking on the widget in Qt Designer or Qt Creator, we see it's called uiPowerDownThreshold
. Searching for that name in the corresponding Python file, we find it's used a couple places. The uses we look at first are in are in the __init__ function, since we need to know which functions are bound to the widget. It looks like the original contents are saved to a list, and then there's a call to api.observe()
, and then:
self.uiPowerDownThreshold.currentTextChanged.connect(
lambda val: control().set({"saveAndPowerDownLowBatteryLevel":
pct2dec(val) }) )
This function uses the D-Bus API, via control().set()
, to actually set the value to whatever the label of the dropdown is. So this is how the power down level is set, but how is it read?
Above the currentTextChanged callback, there's a call to api.observe()
.
api.observe("saveAndPowerDownLowBatteryLevel",
self.updatePowerDownThreshold)
This will invoke the self.updatePowerDownThreshold
method whenever the API setting saveAndPowerDownLowBatteryLevel
is changed - and once upon startup, because it changes from an unknown value to a known one. (This is very convenient for initialisation, since our initialisation and update code is often the same.) We could also retrieve the current value of an api setting by using control().get(['saveAndPowerDownLowBatteryLevel'])
, but we use api.observe
here in case the level is changed later by the HTTP interface or through the mobile app.
Let's have a look at the update function, the last part of our puzzle. It has a bunch of stuff around it!
@QtCore.pyqtSlot(float, name="updatePowerDownThreshold")
@api.silenceCallbacks('uiPowerDownThreshold')
def updatePowerDownThreshold(self, threshold: float):
This is a rather finicky part of the code, and it gives monumentally bad errors if you mess it up. The first decorator exposes the function to PyQt's D-Bus bindings. It contains the type information of the D-Bus function call. This is important, because D-Bus dispatches to different functions based on the type of the data being passed to them. This needs to be stated in the decorator because the decorator implementation pre-dates Python's built-in type information. The name arg is set to the function name, because the pyqtSlot
decorator cannot distinguish between different functions wrapped by the silenceCallbacks
decorator. Without a unique name, it will overwrite each previous function with the subsequent function definition. Always set the name to the name of the function being decorated.
The next decorator, api.silenceCallbacks
, stops the widgets named from firing events. Normally, when a widget is updated, it will update a value in the API. However, since updatePowerDownThreshold
is called when a value in the API is updated, it could cause an infinite loop to update the API value here. So we must always silence any widget we modify in this function. Note that, for safety, api.observe
will actually check we've at least decorated our callback with api.silenceCallbacks
, even if we haven't actually silenced any widgets. Failing to silence a callback is a subtle mode of failure, as QT will not fire an update event if the update is the same as the current state of the widget. Usually, this will stop the infinite update loop. However, if two different updates are being processed by the API at once, they will keep setting the widget to a different value, over and over and over again.
Finally, the function itself takes a threshold, in this case between 0 and 1, and converts it into a percentage which is added to the drop-down list.