I think it's time to finally start a thread about best practices in regards to instrument development. Most of the people working on instruments aren't professional software developers and while Air Manager makes it easy to start working on simple gauges (which is great), as soon as things get a bit more complex, there are quite a few pitfalls on the way. And I think with a bit of guidance from more seasoned developers, many of these could be avoided.
As a preliminary note: These practices are common coding practices, but they aren't meant set in stone... they represent my own experience as someone working with Air Manager for a few years and being a professional software developer for 20 years. There will always be cases where following the rules won't make much sense. And hopefully, other seasoned developers will join me and share their thoughts as well.
In addition, I'd like to offer coaching to anyone starting with the development of complex instruments or panels and would like to invite other seasoned developers to do the same. In many cases, having someone experienced to talk to about problems, concepts, ideas and who'll take a look at the code (especially at the early stages) can vastly speed up the learning curve. So if your interested, just send me a PM and we'll work something out. Often, if your new to programming, you sometimes need hours to solve a problem where others could have told you the solution within minutes. A forum alone isn't the perfect solution to address this, as response times will often be too long.
Documentation:
The right amount of documentation within code is something that can be wildly discussed. Commenting code is necessary so others (or you in a few month or years) still have the chance to understand not only what the code does, but also why. In my opinion, comments should focus on two parts. First of, there should be a preliminary comment about what the code does (in general) as well as limitations, known errors and the like. The second thing that should be documented is when code gets either too complex to be easily readable (e.g. on performance critical parts) or does things that aren't obvious at first glance. And in this case, you shouldn't only comment what the code does, but more importantly why.
For example, if you have a block of complicated code which could be easily simplified if it weren't for this one seldom case where the simple solution won't work that you only found after two hours of testing, this is the typical case where you should document not only the what, but the why. Because another developer (or you, two years from now) might stumble over the code again and will say: "Dang, why did I do this... this can be implemented much easier...".
Fast code vs. readable code
When coding, you will often have to find a balance between a (computational) fast solution and a solution that might be a bit slower but is much easier to read and maintain. Always ask yourself, how often the code is run: If it's run only once when the instrument starts or very seldom (e.g. if the user changes a display mode) take the slow but readable solution. If the code is run at every simulator frame (e.g. in a dataref callback for a float dataref which will permanently change by small amounts, like bus voltage or rpm), take the fast solution. And if it's somewhere in between (e.g. code that is run a few times per second), try to find a balanced solution.
Basic coding
Try to stick to simple naming conventions and don't make things to complicated. This will directly reduce the time you spent debugging and finding typos. And only use prefixes or suffixes when necessary (e.g. if you have both a string and a float variable for the same thing). It's not necessary to type "gbl_SelectedDisplayMode" a hundred times when a simple "mode" would do the trick as well. If necessary, you can add a comment to the declaration of global variables that explains what "mode" is meant in case it's not evident.
Also, use constants, where appropriate. Lua doesn't support constants and enums, but we can just use variables instead and give them upper case names (so they are distinguishable from regular variables). If you have a constant number that you need several times within the code, use a constant. If you have fixed numbers that represent a certain state (e.g. different modes), use constants.
In addition, use arrays instead of numbered variables. Arrays have the advantage, that you can easily loop over them which makes code that much more readable.
Just take a look at the following code snips, is this more readable:
Code: Select all
local img_RedBar1 = img_add("redbar.png", get_column_x(1), 232, nil, nil)
local img_RedBar2 = img_add("redbar.png", get_column_x(2), 232, nil, nil)
local img_RedBar3 = img_add("redbar.png", get_column_x(3), 232, nil, nil)
local img_RedBar4 = img_add("redbar.png", get_column_x(4), 232, nil, nil)
local img_RedBar5 = img_add("redbar.png", get_column_x(5), 232, nil, nil)
local img_RedBar6 = img_add("redbar.png", get_column_x(6), 232, nil, nil)
visible(img_RedBar1, false)
visible(img_RedBar2, false)
visible(img_RedBar3, false)
visible(img_RedBar4, false)
visible(img_RedBar5, false)
visible(img_RedBar6, false)
Code: Select all
local redBars = {}
for i = 1,6 do
redBars[i] = img_add("redbar.png", get_column_x(i), 232, nil, nil)
visible(redBars[i], false)
end
This
Code: Select all
local gbl_flt_TotalFuel = (flt_arr_SimFuel[1] + flt_arr_SimFuel[2]) * 0.3555843
local gbl_flt_FuelFlow = flt_arr_SimFF[1] * 1280.10348
Code: Select all
local totalFuel = (simFuel[1] + simFuel[2]) * KGS_TO_LBS
local fuelFlow = simFuelFlow[i] * KGS_S_TO_GAL_H
Code: Select all
if gbl_int_SelectedMode == 3 or (gbl_int_SelectedMode == 5 and gbl_bool_CylinderHasPeaked == false) then
Code: Select all
if mode == MODE_PERCENT or (mode == MODE_LEAN_FIND and not isPeaked) then
Code: Select all
if time < 8 then
-- a
end
if time >= 8 and time < 16 then
-- b
end
if time >= 16 and time < 24 then
-- c
end
Code: Select all
if time < 8 then
-- a
elseif time < 16 then
-- b
elseif time < 24 then
-- c
end
Redundancy
Try to reduce redundancy as much as possible. If you have two methods that do almost the same, it might be best, to merge them to one method which distinguishes both cases internally. Also, try not to having multiple variables for the same thing. For example, in the following code:
Code: Select all
local btn_pressed = false
local btn_count = 0
local btn_ignore = false
function button_pressed()
btn_pressed = true
end
function button_released()
btn_pressed = false
if not btn_ignore release then
-- do short press action
end
end
-- called by timer
function update()
-- ...
if btn_pressed and not btn_ignore then
btn_count = btn_count + 1
if btn_count > 10 then
-- do long press action
btn_ignore = true
end
end
-- ...
end
Code: Select all
local btn_count = 0 -- -1 pressed, but already handled, 0 depressed, 1+ pressed (duration + 1)
function button_pressed()
btn_count = 1
end
function button_released()
if btn_count > 0 then
-- do short press action
end
btn_count = 0
end
-- called by timer
function update()
-- ...
if btn_count > 0 then
btn_count = btn_count + 1
-- note that btn_count will be 1 higher than in the previous example, as it is incremented once on the button press:
if btn_count > 11 then
-- do long press action
btn_count = -1
end
end
-- ...
end
On the other hand, don't overdue this. For example, rather then this
Code: Select all
local ticks = 0
function draw()
if ticks < 8 then
-- draw used fuel
elseif ticks < 16 then
-- draw remaining fuel
else
-- draw remaining time
end
end
function update()
if isAutoCycle then
ticks = ticks + 1
if ticks >= 24 then
ticks = 0
end
end
...
end
Code: Select all
local FPS = 4
local PAGE_USED = 1
local PAGE_REMAIN = 2
local PAGE_TIME = 3
local ticks = 0
local page = PAGE_USED
function draw()
if page == PAGE_USED then
-- draw used fuel
elseif page == PAGE_REMAIN then
-- draw remaining fuel
elseif page == PAGE_TIME then
-- draw remaining time
else
print("Cannot be, trying to draw invalid page!")
end
end
function update()
if isAutoCycle then
ticks = ticks + 1
if ticks == FPS * 2 then
ticks = 0
page = fif(page == PAGE_TIME, PAGE_USED, page + 1)
end
end
...
end
KISS - Keep it short and simple
Try keeping the code simple. That also means keeping functions short. If your function is longer than about 30 lines, move code into subfunctions. If a simple solution fits 99% of the time, make one function for these case and another one for that one special case where it doesn't fit. This is most often more simple than trying to implement something that fits all cases.
Having to review or debug a 500 line spaghetti function!
Init-Function
Put all the code that creates drawable objects, registers callbacks, etc. into a single init()-Function. That way (at least if you use a serious editor for coding), you can collapse this whole initialization logic out of view, once you begin working on the runtime logic. This is the one case, where you can consequently ignore the "rule of 30" (see above). While we're at it, use a serious code editor (Notepad++, VSCode, ...) with syntax highlighting, code collapsing and split views for coding and use the built-in editor only for debugging.
Prepare for expansion
Imagine you code something like an engine monitor, where you need dedicated EGT values for each cylinder, but the simulator you use only supplies one value per engine. So you come up with a smart solution to extrapolate values for other cylinders base on what you have. The important part is that you put that logic in some dedicated place, for example in the callback code that gets the simulator values and build the rest of the instrument as if you would have dedicated values per cylinder. That way, if you (or any other user) changes to a simulator that has these dedicated values, you just need to remove that single block of code. Or if you come up with an even better solution on how to extrapolate the values, it's easily changed.
Modell View Controller
This is a design pattern that is perfectly suited for more complex instruments, like an PFD or Engine Monitor. The idea of the MVC pattern is to split the code into three parts, the model (=data), the view (=visual representation) and control (=program workflow).
Lets stick to the example of an fuel computer. You'll need to subscribe to a lot of datarefs (voltage, fuel amount, fuel flow) and depending on the state the instrument is in (turned off, self test during power up, normal display or even programming modes) draw different things to the display.
Now we could try to put this all into a huge method (or group of methods) that is registered as a simulator callback, but this will have a huge performance impact. As float values like voltages or fuel flow will constantly changed, our callback would be called for every frame of the simulator.
Instead, we use the MVC pattern: The callbacks from the simulator will store the data in global variables (Model), e.g. update the used fuel. Functions triggered by buttons or timers (e.g. for periodically changing views) will just implement the instruments logic and store the state of the instrument in the same and more global variables (Control), e.g. set the instrument mode (OFF, Normal, Programming) or clear the used fuel. And another function called by a timer at an interval matching the appropriate FPS for the instrument type (e.g. 25 for an PFD or 5 for a fuel computer) will update the displays (View), e.g. write the used fuel to the seven segment display if we're in used fuel mode.
This will keep the code tidy and untangled (no more txt_set in the middle of a calculation block) and reduce load as the expensive draw calls are reduced to an acceptable rate.
Best regards
Florian