This deep dive into QuickApp internals provides the foundation for advanced QuickApp development. Understanding these mechanisms helps you write more efficient, robust, and well-architected QuickApps.
Disclaimer 1: We’re venturing into undocumented territory. Fibaro can change how things work at any time (even documented things!). 😉
Disclaimer 2: This explains how I believe some QuickApp functionality is implemented by Fibaro. It’s most likely not exactly how Fibaro does it, but it should match the observable behavior. If I'm wrong, please correct me.
Prerequisites: Also see Fibaro’s documentation for QuickApp coding: https://docs.fibaro.com
- Classes and Objects Review
- QuickApp Extension Pattern
-
Part 1: QuickApp Class Extension
- Adding Fields to QuickApp Class
- Code Execution Order
- Global Variables and Initialization
-
Part 2: Device Table Structure
- Understanding Device Definitions
- QuickApp Device Properties
- API Access to Device Data
-
Part 3: QuickApp Object Creation
- Class Hierarchy (Device → QuickAppBase → QuickApp)
- Object Instantiation Process
- Accessing Device Properties
-
getVariable()andsetVariable()- QuickApp Variables Implementation
- Best Practices and Gotchas
-
updateProperty()Method- Read-Only vs Persistent Properties
- Events and Triggers
-
updateView()Method- ViewLayout Structure
- UI Event Handling
-
debug(),trace(),warning(),error()- TAG System Implementation
-
fibaro.call()Mechanism- Public Method Exposure
- REST API Access
-
- Single-Threaded "Operator" Model
- Synchronous vs Asynchronous Calls
- Deadlock Prevention
Let's recap from the previous post:
✅ Classes are templates for creating objects
✅ Objects are implemented as Lua tables with key-value pairs
✅ QuickApp is a class provided by Fibaro for creating QA devices
✅ Extension Pattern: We extend QuickApp with our own method definitions
✅ Lifecycle: Fibaro loads our code → creates QuickApp object → calls onInit()
Key point: onInit() is always called after all code has been loaded, regardless of where it's defined in your code.
Just to prove that QuickApp is a real class:
QuickApp.myField = "Hello" -- Extend the class with a field
function QuickApp:onInit()
self:debug(self.myField) -- "Hello" - copied to our object instance
endBehind the scenes: Fibaro's class function creates userdata objects that we can't easily inspect, but they behave like Lua tables for most practical purposes.
During code loading (top level):
-- ✅ Can extend the class
QuickApp.myField = "default value"
-- ❌ Can't use self - object doesn't exist yet
-- self:debug("This will fail!")After object creation (in methods):
function QuickApp:onInit()
-- ✅ Can use self - object now exists
self:debug("Object is alive!")
endWhy this matters: The main purpose of QA code is to extend the QuickApp class with your methods. Fibaro creates the object and calls your onInit() after all code has loaded.
-- ✅ Good practice - main initialization in onInit()
function QuickApp:onInit()
-- Code has loaded, object exists, can use self
self:debug("Starting up...")
end
-- ⚠️ Careful with order if initializing outside onInit()
local myVar = "Hello" -- This works - runs at load timemyQuickApp = nil
local function myPrint(str)
myQuickApp:debug("TEST:", str)
end
function QuickApp:onInit()
myQuickApp = self -- Save reference for later use
myPrint("Hello") -- Now we can use it
endmyPrint() until onInit() has run and set up the variable.
Fibaro actually assigns the QuickApp object to a global variable quickApp, but it doesn't get assigned until after :onInit() exits:
-- ❌ This will fail:
local function foo()
quickApp:debug("OK")
end
function QuickApp:onInit()
foo() -- Error: "attempt to index a nil value (global 'quickApp')"
end-- ✅ This works:
local function foo()
quickApp:debug("OK")
end
function QuickApp:onInit()
setTimeout(foo, 0) -- Delayed execution - prints "OK"
endWhy? The setTimeout callback runs after onInit() has finished and quickApp has been assigned.
The most common predefined methods for the QuickApp class:
function QuickApp:debug(...) -- Logging with automatic tag
function QuickApp:trace(...) -- Same as debug with different tag
function QuickApp:warning(...) -- Warning-level logging
function QuickApp:error(...) -- Error-level logging
function QuickApp:getVariable(varName) -- Get QuickApp variable
function QuickApp:setVariable(varName, value) -- Set QuickApp variable
function QuickApp:updateProperty(property, value) -- Update device property
function QuickApp:updateView(element, type, value) -- Update UI element
function QuickApp:createChildDevice(properties, constructor) -- Create child
function QuickApp:removeChildDevice(id) -- Remove child
function QuickApp:initChildDevices(map) -- Initialize childrenAll devices on the HC3 are represented as Lua table structures accessible via API:
device = api.get("/devices/78")
print(device.id) -- Prints the device IDTip: Check the “Swagger” page of your HC3 (button with the {…} icon in the lower-left of the web UI) to see all available API calls.
{
name = "MyQuickApp", -- Device name from Web UI
id = 78, -- Assigned deviceId number
roomID = 219, -- Room assignment (0 = unassigned)
type = "com.fibaro.binarySwitch", -- Device type (determines UI/actions)
baseType = "com.fibaro.actor", -- Common base type
enabled = true, -- Device enabled state
visible = true, -- Device visibility
isPlugin = true, -- Always true for QuickApps
interfaces = { -- Supported interfaces
"light",
"quickApp"
},
parentId = 0, -- Parent device (for QuickAppChildren)
properties = { -- Device properties
value = true, -- Main device value
dead = false, -- Device status
deviceIcon = 90, -- UI icon
categories = { "lights" }, -- UI categories
quickAppVariables = { -- Your QuickApp variables
-- List of {name="varName", value="varValue"} objects
},
viewLayout = { -- UI definition structure
-- Button, label, slider definitions
},
uiCallbacks = { -- UI event mappings
{
name = "mySlider",
callback = "slider",
eventType = "onChanged"
}
},
mainFunction = "-- [DEPRECATED] Your Lua code here"
},
actions = { -- Standard device actions
toggle = 0, -- Number = parameter count
turnOff = 0,
turnOn = 0
},
modified = 1590043821, -- Unix timestamp
created = 1590043821 -- Unix timestamp
}Update: The code is no longer stored in
mainFunction. Modern QuickApps use separate files associated with the device. More on that later.
Update device properties:
api.put("/devices/78", {properties = {value = false}}) -- Update value
api.put("/devices/78", {enabled = false}) -- Disable deviceThese API calls can also be used from Scenes.
Device -- Base device class
↓
QuickAppBase -- Base for QuickApp classes
↓
QuickApp -- Your QuickApp class
QuickAppChild -- Child device class--- Simplified creation process ---
deviceId = 78
deviceTable = api.get("/devices/" .. deviceId) -- Get device definition
quickApp = QuickApp(deviceTable) -- Create object with device data
if quickApp.onInit then
quickApp:onInit() -- Call initialization
endNote: This is most likely not exactly how Fibaro implements it, but the end result is the same.
The QuickApp constructor copies the most important device table values into the object:
function QuickApp:onInit()
self:debug("Name:", self.name) -- Device name
self:debug("Device ID:", self.id) -- Device ID
self:debug("Type:", self.type) -- Device type
self:debug("Value:", tostring(self.properties.value)) -- Main value
endResult: Some device table fields become accessible via self.*
function QuickApp:getVariable(varName)
function QuickApp:setVariable(varName, value)The self:getVariable(varName) method searches through the quickAppVariables list:
-- QuickApp variables are stored as:
quickAppVariables = {
{name = "varName1", value = "value1"},
{name = "varName2", value = "value2"},
-- ...
}
-- Implementation (simplified):
function QuickApp:getVariable(varName)
for _, var in ipairs(self.properties.quickAppVariables) do
if var.name == varName then
return var.value
end
end
return "" -- ⚠️ Returns empty string, not nil!
end❌ Performance issue: Linear search through the list—gets slower with more variables.
❌ No nil distinction: Returns "" instead of nil for missing variables.
-- ❌ Can't distinguish between missing and empty:
local val = self:getVariable("myVar")
if val ~= "" then
self.myField = val
end
-- ✅ Would be better with nil:
-- self.myField = self:getVariable("myVar") or self.myFieldFrom the web UI: Variables added via the web UI are always stored as strings.
From code: You can store any Lua type:
self:setVariable("myTable", {a = 9, b = 19}) -- Stores as table
local tbl = self:getVariable("myTable") -- Retrieved as tableQuickApp.myField = "default value" -- Class-level default
function QuickApp:onInit()
local val = self:getVariable("myVar")
if val ~= "" then
self.myField = val -- Override with user setting
end
self:debug(self.myField)
endfunction QuickApp:updateProperty(propertyName, value)❌ Direct property updates (temporary):
self.properties.value = 42 -- Updates locally but doesn't persistThe main problem with doing this is that no event is generated to indicate the QA’s property changed. Instead, use self:updateProperty(...).
✅ Persistent property updates:
self:updateProperty("value", 42) -- Persists and triggers events- Persistence: Changes are saved to the device table
- Events: Some properties trigger system events/notifications
- Consistency: Other components (Scenes) are notified of changes
Example:
-- Update the main device value
self:updateProperty("value", 42)
-- This updates self.properties.value AND persists itWhen you call self:setVariable(varName, value):
- Updates the
self.properties.quickAppVariableslist - Fires a
DevicePropertyUpdatedEvent - Sends the entire variable list as the event value (not just the changed variable)
Performance consideration: Large variable lists create large events.
When you modify QA code and save it:
-- Fibaro essentially does:
self:updateProperty("mainFunction", newCode)This triggers a DevicePropertyUpdatedEvent with the entire code in the event! For large QuickApps (3000+ lines), this creates very large events.
You can update device properties via API:
api.put("/devices/88", {enabled = false}) -- Disable device (causes restart)updateProperty() avoid restarts.
function QuickApp:updateView(element, type, value) This function updates the UI elements (buttons, labels, sliders) defined for your QuickApp.
Updating button text:
self:updateView("myButton", "text", "New text for this button")Updating slider value:
self:updateView("mySlider", "value", "50") -- Must be string!UI element definitions are stored in the viewLayout property. When you update elements, changes are reflected in this structure.
You can achieve the same result using the API directly:
api.post("/plugins/updateView", {
deviceId = self.id,
componentName = "myButton",
propertyName = "text",
newValue = "New Text"
})UI interactions generate events that trigger QuickApp methods:
Button event structure:
{
eventType = "onReleased",
elementName = "button1",
deviceId = 985,
values = {nil}
}Slider event structure:
{
eventType = "onChanged",
elementName = "slider",
deviceId = 985,
values = {39} -- Current slider value
}Define methods with the same name as your UI elements:
function QuickApp:button1(event)
self:debug("Button clicked")
end
function QuickApp:slider(event)
local value = event.values[1]
self:debug("Slider value set to", value)
-- Best practice: Update slider value to prevent drift
self:updateView("slider", "value", tostring(value))
endThe challenge: There’s no built-in function to read current UI element values.
The solution: Parse the viewLayout structure:
local function getView(deviceId, name, typ)
local function find(s)
if type(s) == 'table' then
if s.name == name then
return s[typ]
else
for _, v in pairs(s) do
local r = find(v)
if r then return r end
end
end
end
end
local viewData = api.get("/plugins/getView?id=" .. deviceId)
return find(viewData["$jason"].body.sections)
end
-- Usage:
local buttonText = getView(self.id, "myButton", "text")
local sliderValue = getView(self.id, "mySlider", "value")function QuickApp:debug(...)
function QuickApp:trace(...) -- Same as debug with different tag
function QuickApp:warning(...) -- Warning-level logging
function QuickApp:error(...) -- Error-level loggingThese methods are similar to fibaro.debug(tag, ...) but with automatic tagging:
-- Simplified implementation:
function QuickApp:debug(...)
local str = table.concat({...})
fibaro.debug(__TAG, str)
end__TAGis a global variable set to"QuickApp" .. self.idby default- You can customize it:
__TAG = "MyApp"changes the log prefix - Variable arguments:
debugaccepts any number of arguments via...
function QuickApp:onInit()
self:debug("Starting QuickApp") -- "QuickApp123: Starting QuickApp"
self:warning("This is a warning") -- Warning-level message
self:error("Something went wrong") -- Error-level message
-- Multiple arguments
self:debug("Value:", self.properties.value, "Type:", type(self.properties.value))
end
-- Custom tag
__TAG = "MyCustomApp"
function QuickApp:onInit()
self:debug("Custom tagged message") -- "MyCustomApp: Custom tagged message"
endAll QuickApp methods can be called remotely:
fibaro.call(deviceId, methodName, arg1, arg2, ...)Example: Set a QuickApp variable on another device:
fibaro.call(55, "setVariable", "Test", 77)- From other QuickApps via
fibaro.call() - From Scenes via
fibaro.call() - From external systems via REST API
External systems can call your methods:
POST http://<HC3_IP>/api/devices/<deviceId>/action/<methodName>
Content-Type: application/json
{
"args": [value1, value2, ...]
}Problem: Sometimes you don't want to expose internal logic.
Solution 1: Keep functions outside the QuickApp class:
local quickApp = nil
local interval = 30
local function loop() -- Private function
-- Poll external server and update UI
fibaro.setGlobalVariable("myValue", value)
quickApp:updateView("myLabel", "text", tostring(value))
end
function QuickApp:onInit()
quickApp = self
setInterval(loop, interval * 1000)
endSolution 2: Pass self as a parameter to avoid a global variable:
local interval = 30
local function loop(self) -- Private function with self parameter
-- Poll external server and update UI
fibaro.setGlobalVariable("myValue", value)
self:updateView("myLabel", "text", tostring(value))
end
function QuickApp:onInit()
setInterval(function() loop(self) end, interval * 1000)
endTrade-offs:
- Global variable approach: Simpler, less parameter passing
- Parameter approach: No global state, more explicit
Each QuickApp has one "Operator" - QuickApps are single-threaded.
Local method call:
self:turnOn() -- Simple function call: self.turnOn(self)Remote method call:
fibaro.call(77, "turnOn") -- Complex inter-process communication- Operator-55 calls
fibaro.call(77, "turnOn") - Operator-55 waits for acknowledgment
- Operator-77 receives request in "mailbox"
- Operator-77 checks if method exists:
if self['turnOn'] and type(self['turnOn']) == 'function' then self['turnOn']() -- Call the method end
- Operator-77 acknowledges completion back to Operator-55
Self-calling deadlock:
fibaro.call(self.id, "turnOn") -- QuickApp calls itselfWhat happens:
- Operator waits for acknowledgment
- Operator can't check mailbox (busy waiting)
- Request never gets processed
- Deadlock!
Fibaro introduced async calls (firmware 5.031.33+):
fibaro.useAsyncHandler(true) -- Default: async (recommended)
fibaro.useAsyncHandler(false) -- Synchronous (old behavior)Synchronous (old way):
fibaro.useAsyncHandler(false)
self:debug("Calling turnOn")
fibaro.call(self.id, "turnOn") -- Waits 5+ seconds
self:debug("Done")Asynchronous (new way):
fibaro.useAsyncHandler(true) -- Default
self:debug("Calling turnOn")
fibaro.call(self.id, "turnOn") -- Returns immediately
self:debug("Done") -- Prints immediatelyCurrent limitation: fibaro.call() doesn't return values or error messages.
REST API advantage: External REST calls do return error messages:
HTTP 404: Device not found
HTTP 400: Method does not existFuture possibility: Fibaro may add return values and error handling to fibaro.call().
- Use async mode (default) to avoid deadlocks
- Keep timeouts in mind for sync calls (5+ second timeout)
- Use REST API for external integrations with error handling
- Design methods carefully - they become public interfaces
QuickApp Architecture:
✅ Class extension patterns and field management
✅ Device table structure and property access
✅ Object creation and initialization lifecycle
Core Methods:
✅ Variable management (getVariable/setVariable)
✅ Property persistence (updateProperty)
✅ UI manipulation (updateView)
✅ Logging system with automatic tagging
Communication:
✅ Inter-QuickApp method calls (fibaro.call)
✅ Public method exposure and privacy strategies
✅ Single-threaded execution model
✅ Async vs sync call behavior
Property Management:
- Treat
self.*values as read-only unless using update methods - Use
updateProperty()andsetVariable()for persistence - Be aware of event generation and performance implications
Method Design:
- All QuickApp methods become publicly accessible
- Consider keeping private logic outside the class
- Design methods as public interfaces
Execution Model:
- QuickApps are single-threaded with one "Operator"
- Async calls prevent deadlocks (use default async mode)
- No return values from
fibaro.call()(yet)
In Part 3, we'll explore:
- QuickAppChildren - Creating and managing child devices
- Advanced UI patterns - Complex viewLayout structures
- Event system - Device events and triggers in detail
- Performance optimization - Best practices for resource efficiency
- Error handling - Robust QuickApp development patterns
| Method | Usage | Notes |
|---|---|---|
getVariable(name) |
Get QA variable | Returns "" if not found |
setVariable(name, val) |
Set QA variable | Triggers events |
updateProperty(prop, val) |
Update device property | Persists changes |
updateView(elem, type, val) |
Update UI element | Value must be string |
debug(...) |
Log message | Uses __TAG prefix |
| Pattern | Usage | Notes |
|---|---|---|
self:method() |
Local call | Direct function call |
fibaro.call(id, "method", ...) |
Remote call | Async by default |
fibaro.useAsyncHandler(bool) |
Set call mode | true = async, false = sync |
| Practice | Reason | Example |
|---|---|---|
Use updateProperty() |
Persistence + events | self:updateProperty("value", 42) |
| Keep private functions outside class | Avoid public exposure | local function helper() ... end |
Always use string for updateView() |
API requirement | self:updateView("slider", "value", "50") |
| Update slider values in handlers | Prevent drift | self:updateView("slider", "value", tostring(ev.values[1])) |