flua — user guide for QA developers
flua runs Fibaro QuickApps offline on your Mac or PC. You write the same
Lua you'd write on the HC3 — QuickApp, fibaro, api, net, mqtt — and
flua provides the rest of the world: a simulated HC3, real network access, a
virtual clock, a proper debugger with breakpoints, and a one-command path back
to the HC3 as a .fqa file.
The HC3 editor is tiny and debugging there means print() statements. flua
exists so you can develop in your real editor, with real tooling, and upload
when it works.
- flua — user guide for QA developers
Install
Python 3.11+. lupa (which bundles Lua) is the only runtime dependency.
pip install fibaro-flua # from PyPI — installs the flua command
The PyPI page carries this guide in full. The wheel also ships the
examples/ QuickApps — find where they are installed with:
flua --examples # prints the directory; copy the examples out
flua --api local $(flua --examples)/timers.lua # ...or run one in place
From a checkout (development):
cd flua
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
flua --version
Your first QuickApp
A QuickApp is an ordinary Lua file. QuickApp:onInit runs exactly like on
the HC3, and self:debug prints immediately (unlike the HC3, where you'd
only see it in the debug window):
--%%name:hello
-- --------------- EOH ---------------
function QuickApp:onInit()
self:debug("I started")
setTimeout(function() self:debug("one second later") end, 1000)
end
Save it as hello.lua and run it:
.venv/bin/flua hello.lua
Every .lua file you pass is a QuickApp — it gets QuickApp, fibaro,
api, net, mqtt, timers, and runs onInit exactly like on the HC3.
Anything that has nothing left to do (no timers, no network connections)
makes flua exit; QAs with timers or open connections keep running.
Running QAs
.venv/bin/flua script.lua # one QA
.venv/bin/flua qa1.lua qa2.lua # several QAs, isolated, can talk to each other
.venv/bin/flua -e 'setTimeout(function() print("hi") end, 100)'
.venv/bin/flua --run-for 5 script.lua # at least 5 virtual s, then exit when idle
.venv/bin/flua --run-for -3 script.lua # exactly 3 virtual s
Virtual time — great for testing long-running logic (--run-for counts
virtual seconds: --instant --run-for -5 runs five simulated seconds, and
an interval firing once per simulated second fires exactly five times):
.venv/bin/flua --speed 60 script.lua # 60x faster
.venv/bin/flua --instant script.lua # timers fire now, os.time() jumps ahead
fibaro.sleep(ms) suspends the calling QA for ms of virtual time — other
QAs keep running, and the sleep follows --speed/--instant like a timer.
Messages destined for the QA while it sleeps (timer callbacks, actions, UI
and network events) are queued and delivered, in arrival order, after the
sleeping callback resumes. It works anywhere in QA code — top level,
onInit, callbacks — because the QA identity is registered before the file
executes; only outside a QA (main-state code) does it raise an error.
VS Code setup
The repo ships .vscode/launch.json with five configurations (the
--tool setup template has the same five, with the debug interpreter
resolved to flua's absolute path — VS Code doesn't inherit the shell's
PATH on macOS, so a bare flua would not be found; the run configurations
use the selected Python interpreter), all running with --api local — the
offline playground):
- Flua: Run Current File — runs the file in the panel.
- Flua: Run Current File (Terminal) — runs it in the integrated terminal
(pick this one for
self:debugoutput you can scroll). - Flua: Run Current File (UI) — runs it and serves the UI viewer at
http://127.0.0.1:8090/(if 8090 is busy the server picks the next free port — open the printed URL). - Flua: Debug Current File (mobdebug) — real debugging: breakpoints,
stepping, variable inspection. Install the VS Code extension
Lua MobDebug (
alexeymelnichuk.lua-mobdebug), open your QA, press F5 with the mobdebug configuration selected. It uses the project venv'sfluaexecutable on macOS andflua.exeon Windows. flua waits for the debugger before running your code. - Flua: Debug Current File (UI+mobdebug) — the same, plus the UI
viewer server (
--ui 8090): debug your QA while its UI is rendered athttp://127.0.0.1:8090/.
While you step through code, print/self:debug output appears immediately
— logs never queue behind a paused program.
The flua CLI writes stdout and stderr as UTF-8 on all platforms, including when a debugger captures output through pipes on Windows. This preserves Unicode text and the startup rocket in VS Code's debug console.
Lua Language Server support: the repo's .luarc.json declares Lua 5.5
with the HC3 globals and loads the bundled .luals/ type library — LuaLS
definitions for QuickApp, fibaro, api and json — so QA code in the
repo gets autocomplete and parameter hints. flua --tool setup ships the
same configuration (and the type library) into scaffolded projects for pip
users; the two copies are kept byte-identical by a test.
The fastest loop for tinkering:
.venv/bin/flua --watch script.lua
flua restarts the QA whenever you save the file (or any --%%file file).
Ctrl-C stops it. Works on top of the debugger too.
Without the repo (pip install)
Installed from PyPI you don't have this checkout's developer setup — the
VS Code run/debug configs, the Lua Language Server settings, and the agent
files (Copilot skills/instructions, AGENTS.md). One command scaffolds a
QA project directory with all of it:
flua --tool setup # the current directory
flua --tool setup my-project/ # a project directory (created if missing)
It writes .vscode/launch.json (run in the panel, run in the terminal,
mobdebug, and mobdebug with the UI viewer), .vscode/extensions.json
(VS Code will offer to install the Python, Lua MobDebug,
Lua Language Server and Markdown Preview Mermaid extensions — the
last one renders the diagrams in docs/tutorial/),
.luarc.json (Lua 5.5 + the HC3 globals, with the bundled .luals/
type library — LuaLS definitions for QuickApp, fibaro, api and
json, so QA code gets autocomplete and parameter hints), AGENTS.md
and the .github/ skills, instructions and prompts. Existing files are
kept; --force overwrites.
QA directives (--%%)
Directives are ordinary Lua comments at the top of the file. flua parses them before running; the HC3 ignores them — the same file runs in both places. Parsing stops at the end-of-header marker:
--%%name:my-qa
-- --------------- EOH ---------------
-- nothing below this line is parsed as a directive
| Directive | Meaning |
|---|---|
--%%name:x |
the device/QA name (_FLUA.config.name) |
--%%type:com.fibaro.binarySwitch |
device type (defaults to binarySwitch; unknown types are an error) |
--%%properties:value=false |
default device properties (plural form) |
--%%property:value=true |
raw property, scalar values; repeatable, merges |
--%%var:name=expr |
initializes a QuickApp variable; expr is a Lua expression — 'text', 42, {a=1}, config.color, os.getenv("X") (one per line). An optional quoted string after a top-level comma is the package value distribution packages get: --%%var:name=expr,"<placeholder>" |
--%%clobber:name=value |
Plua-compatible spelling of the package value (replaces --%%var:name's value in distribution packages) |
--%%uid:... |
sets quickAppUuid |
--%%description:... |
sets userDescription |
--%%model:... |
sets model |
--%%build:7 |
sets buildNumber (a number) |
--%%manufacturer:... |
sets manufacturer |
--%%file:lib.lua,lib |
extra QA file, loads before main (see below) |
--%%speed:60 |
virtual time speed (global) |
--%%instant:true |
instant mode (global) |
--%%maxhours:48 |
stop after 48 virtual hours (global) |
--%%time:speed=2,instant=true,hours=48,start=2027/10/6 12:00:20 |
combined runtime settings (global) |
--%%time:2027/10/6 12:00:20 |
bare form: set the virtual start time |
--%%u:{label="lbl",text="Status"} |
one UI row (see below); repeatable, order kept |
--%%useUiView:true |
render the new uiView format instead of the legacy viewLayout (default false) |
--%%mode:offline |
pin this QA to the simulated HC3 (see below) |
--%%mode:online |
force this QA online against the real HC3 |
--%%mode:proxy |
proxy mode: mirror this QA onto the HC3 (online only, see below) |
--%%mode:proxy,noUI |
proxy mode, but leave the proxy's UI untouched (you edit it in the HC3's UI editor) |
--%%debug:refreshState=true,api=true,http=true |
debug logging: refreshStates events / api.* calls / net.HTTPClient requests (global) |
--%%debug:true |
shorthand for all three debug channels (:false disables them) |
--%%loglength:120 |
debug line length cap (default 120) |
--%%warn:true |
extra runtime warnings for things the HC3 stays silent about (global) |
--%%db:house.json |
seed the simulated HC3 from a JSON file (devices, rooms, scenes, globalVariables, alarms, profiles) |
--%%db:+house.json |
seed and persist emulator state back to the file (see below) |
--%%location:latitude=59.33,longitude=18.07 |
pin the sim's location (device 1's sunrise/sunset) — deterministic sun times without touching .flua.lua |
The legacy --%%offline:true and --%%proxy:true still work as aliases for
--%%mode:offline and --%%mode:proxy (an explicit --%%mode wins when
both appear).
The emulator database (--%%db)
--%%db:house.json seeds the simulated HC3 from a JSON file — the same
schema as examples/house.json and the --seed flag (the directive wins
over --seed). --%%db:+house.json additionally persists emulator state
back to the file as it changes (atomic writes; the file must exist —
echo '{}' > db.json creates an empty one). What is persisted depends on
the mode, following each mode's source of truth:
- offline — the file is the house: devices, rooms, scenes,
globalVariables, alarms and profiles, plus each QA's state (properties
and quickAppVariables) and children, under a
qaStatesection keyed by QA name. - online — the real HC3 owns the house data; only the emulator's own
artifacts are written back:
qaStateandglobalVariables. - proxy — everything worth keeping is already on the HC3; the
+is ignored with a warning (seed still applies).
Running QAs are never persisted as devices — they are rebuilt from code each
run. Children survive restarts and re-register under their parent QA, so a
QA's usual api.get("/devices?parentId="..self.id) startup check finds
them. QA state is keyed by QA name (not the per-run id), so persisted state
is stable across runs; --%%var and the other property directives win over
persisted values. internalStorage (self:internalStorageSet/Get) is
persisted with the QA's state too — plugin variables survive runs like on
the HC3.
Names: --%%name:value for scalars,
--%%name:sub1=val1,sub2=val2 for subparameters. A typo'd directive is
reported at runtime (a unknown --%% directive warning) and flua --check
flags it too.
Debug logging
--%%debug:refreshState=true,api=true,http=true turns on per-category debug
logging (any subset; it is a global directive, so an --%%include'd file
can carry
it). --%%debug:true is shorthand for all three channels on:
refreshState=truelogs every refreshStates event flua sees in a short form — the event type plus theid/deviceId/name/property/value/newValuedata fields that are present;api=truelogs the QA'sapi.*andapi.hc3.*calls asapi GET /devices/...;http=truelogs usernet.HTTPClientrequests — not theapi.*calls.
--%%loglength:120 caps the debug line length (default 120; a cut line ends
with ...).
--%%warn:true turns on extra runtime warnings for things the HC3 never
complains about — starting with api error codes: when an api.get/post/
put/delete (or api.hc3.*) returns a 4xx/5xx — or a transport failure —
the emulator logs it, because most QA code silently ignores those status
codes. Flua's own log lines are visually distinct from QA logs: a [FLUA]
level column plus the QA tag the message belongs to:
[02.10.2026][17:32:41][FLUA ][my-qa5000]: api GET /devices/9999 -> 404
HTML in log messages
The HC3 console renders a small HTML subset in QA log lines; flua renders
it to the terminal as well — print and fibaro.debug/trace/warning/
error messages only (flua's own diagnostic lines are left alone):
<b>/<strong>,<i>/<em>,<u>— bold, italic, underline<font color="red">/<span style="color:blue">— text color (named colors and#rrggbb)<table>/<tr>/<td>/<th>— a table with padded columns<ul>/<ol>/<li>— bullet / numbered lists<br/>— a line break- anything else loses its tags, keeping the content
With colors on, formatting becomes ANSI; with --color never (or a
non-terminal) the tags are stripped instead, so the text stays readable.
QuickApp UI (--%%u)
Each --%%u: line defines one row of the QuickApp UI; several elements can
share a row with {{...},{...}}. flua translates the rows into both UI
property structures the HC3 understands — the legacy viewLayout and the
new uiView — plus the uiCallbacks table that routes UI events to your
QuickApp methods. useUiView (default false) selects the new format:
--%%u:{label="statusLbl",text="Status: Ready"}
--%%u:{button="onBtn",text="Turn On",onReleased="turnOn"}
--%%u:{switch="autoSwitch",text="Auto Mode",value="false",onReleased="handleSwitch"}
--%%u:{slider="dimSlider",text="Brightness",min="0",max="100",value="50",onChanged="handleSlider"}
--%%u:{select="modeSelect",text="Mode",value="1",onToggled="handleSelect",
-- options={{type='option',text='Economy',value='1'},{type='option',text='Comfort',value='2'}}}
--%%u:{multi="tagMulti",text="Tags",values={"1","3"},onToggled="handleMulti",
-- options={{type='option',text='Tag A',value='1'}}}
--%%u:{{button="onBtn",text="On",onReleased="turnOn"},{button="offBtn",text="Off",onReleased="turnOff"}}
Elements: label, button, slider, switch, select, multi. Callbacks
(onReleased, onChanged, onToggled, …) name QuickApp methods; sliders
take min/max/step/value, selects and multis take options (each
{type='option',text=…,value=…}) plus value/values. Values in the
directive are Lua literals — strings need quotes, numbers and true/false
don't. Long rows may continue on following comment lines. At runtime the
device's properties.viewLayout, properties.uiView, and
properties.uiCallbacks hold the generated structures, and
self:updateView(elm, prop, value) updates them.
At runtime a single select is set with updateView(elm, "selectedItem", value)
and a multi select with updateView(elm, "selectedItems", {value1, value2, …}).
Like on the HC3, an empty list must be json.array() — the marked empty
array encodes as [], while a plain empty table {} encodes as an object
and breaks the UI. The HC3's own demos use json.array() for exactly this:
self:updateView("tagMulti", "selectedItems", json.array()) -- [] not {}
The UI viewer
flua --ui [PORT] serves the viewer itself over HTTP, alongside the
simulated HC3 API — no file to open:
.venv/bin/flua --ui 8090 examples/ui.lua # UI viewer on http://127.0.0.1:8090/
open http://127.0.0.1:8090/ # press Connect; the base URL matches
The page defaults its API address to its own origin, so a busy port falling
back to the next free one needs no editing. The page ships inside the flua
package (src/flua/setup_templates/viewer/index.html is the source);
--tool setup does not copy it — the served page can also point at another
machine running flua.
The viewer polls GET /flua/devices (the emulated devices only — in online
mode the HC3's own QAs stay out of the list), renders properties.uiView
(labels,
buttons, sliders, switches, single and multi selects — styled after the HC3
look), and sends interactions back through GET /plugins/callUIEvent — the
same contract the real HC3 UI uses, so your onReleased/onChanged/
onToggled methods fire exactly as on the controller. self:updateView
changes land live (the viewer merges device.view over the static
properties). The API server stays up after the QA's timers drain, so the
viewer keeps working while you tweak the file.
On top of the QA's own UI, the viewer renders the device type's default
view (the elements the HC3 adds itself): a binarySwitch gets a colored
TRUE/FALSE state label and Turn On/Turn Off buttons, a multilevelSwitch
adds a dimmer slider, sensors get a live value label, and so on — even when
the QA defines no --%%u directives at all. The viewer fetches them from
GET /flua/embeddedUI (a flua-only channel) and prepends them above the
custom UI; the elements are never stored in the device's viewLayout/
uiView properties, so they cannot leak into a .fqa export. Their controls
invoke device actions (POST /devices/{id}/action/{name}), and their
labels track the watched device properties (e.g. value), both exactly as
the HC3 client behaves. The definitions live in a table in
src/flua/devices/uielements.py — add a QA type there to give it a default
view.
For the real HC3 UI, use proxy mode instead (--%%mode:proxy): the mirror
QA on the HC3 renders the genuine UI and funnels every interaction back to
the emulated QA.
The virtual clock starts now unless you set a start time — handy for
testing dates: leap years, DST switches, New Year logic. Combine with
--instant to fast-forward through the interesting moments:
.venv/bin/flua --start "2027/12/31 23:59:50" script.lua
# or in the file: --%%time:start=2027/12/31 23:59:50,instant=true
The Lua config file
.flua.lua (in the directory you run from, or ~/.flua.lua; the legacy
~/.plua/config.lua also works) is Lua data, loaded once and merged into
_FLUA.config — handy for per-machine settings:
-- .flua.lua
return {
user = "alice",
pwd = os.getenv("HC3_PASSWORD"), -- reads the .env chain
colors = { "red", "green" },
}
QA code sees it as _FLUA.config.user, and --%%var expressions can use it:
--%%var:color=config.colors[1]. --%% annotations win over the file. A
faulty --%%var expression (a syntax error, indexing a nil key) is a
startup error — flua names the variable and exits 1 — but an expression
that evaluates to nil (config.foo, os.getenv("MISSING")) simply leaves
the variable unset.
Variables in packages
A --%%var can declare a package value: a quoted string after the last
top-level comma of the expression —
--%%var:pwd=config.secretPwd,"<set to service password>". Commas inside
the expression (table literals) or inside the placeholder itself are fine.
--%%clobber:name=value is the legacy Plua spelling of the same thing.
Which value lands in a package depends on where the package goes:
- Distribution (
--tool pack,flua export, the.fqayou share or post on the forum): the package value, the evaluated value when no package value was declared. The main file's--%%varheader lines are rewritten to these literals, so no expression — and no secret literal — travels in the file content. - Your own HC3 (
--tool uploadQAas a new QA): always the real evaluated values — the placeholder is a distribution concern, your controller gets the credentials the QA needs. --tool updateQAnever syncs variables: they are initializers, and the runtime values on your HC3 win.
Packaging evaluates the expressions the same way bootstrap does ({config, os} in scope) but never loads or runs QA code — a faulty expression is a
loud packaging error naming the variable, and an expression that evaluates
to nil leaves the variable out of the package.
A location table configures the simulated controller's location — device
1's sunriseHour/sunsetHour are computed from it:
return {
location = { latitude = 59.3293, longitude = 18.0686 }, -- Stockholm
}
Precedence: the built-in default (Stockholm) < the db/seed file's
location < .flua.lua < --%%location (the main QA's directive) < a
runtime PUT /settings/location. Combine it with --%%time:start=... for
a fully deterministic sun: a known place on a known date.
os.getenv(name) reads through flua's environment chain: the local .env
in the directory you run from, then ~/.env, then the shell environment.
Files are plain KEY=value lines (comments and quotes supported) and are
re-read when they change — handy for API tokens and per-machine settings
that don't belong in the QA code.
The --%%include defaults directive
A QA can pull its defaults from another file — per-QA, explicit, and the
path resolves relative to the main file (like --%%file):
--%%include:defaults.lua -- inserts that file's directives as defaults
--%%name:LocalName -- this QA's own directives override them
-- defaults.lua — same --%% directive syntax as a QA header
--%%mode:offline # this project normally runs offline
--%%description:local playground QA
The included file's directives are defaults: the QA's own win per key, the
mode family overrides as a whole (a QA can opt out of an included
--%%mode:offline), and --%%file lists append (defaults first, a
redeclared name wins). Includes nest — each path relative to the file
carrying the include line — and cycles are skipped; a missing include is a
loud error. This replaces the old .directives working-directory defaults
(the file was ambiguous when QAs lived in different subdirectories — each
QA now declares its own defaults explicitly).
Multi-file QAs
On the HC3 a QA is a set of named Lua files — one is main. flua keeps your
files on disk and declares the extras in the main file:
--%%file:lib.lua,lib
--%%file:util.lua,util
-- --------------- EOH ---------------
print(helper())
Files load in declaration order, main loads last. Paths resolve relative
to the main file's directory. A $ prefix instead loads a library bundled
with flua (src/flua/lua/lib/, shipped in the wheel — plain Lua, so it
also runs on the HC3 and travels in the .fqa like any QA file):
--%%file:$event_mgr.lua,em -- flua's bundled event-manager library
A missing bundled library (or a path-escape attempt like $../x.lua) is a
loud load error. See examples/multifile.lua. The HC3's file API works
offline too (api.get('/quickApp/' .. _FLUA.qaId .. '/files') etc.).
--%%file files are the QA's shipped files — they travel into the .fqa
(--tool pack, uploads). Test-only helpers (mock-server handlers, assertion
libraries) can stay out of the package: plain Lua require("name") finds
name.lua next to the main file at runtime, and require'd files are never
packaged.
The offline HC3
Online mode — the real HC3
The backend is picked from the main QA's directives — its header over
any --%%include'd defaults (see below), before the engine starts:
- an explicit
--api local|remotealways wins; - otherwise,
--%%mode:online/--%%mode:proxyin the main QA's directives selects the real HC3,--%%mode:offlinethe offline sim; - otherwise, offline — the default: the simulated HC3, no credentials
needed. Online is opt-in (
--%%mode:online|proxy, or--api remote), and requires HC3 credentials in the environment (HC3_URL/HC3_HOST); without them flua exits with an error.
--%%mode:offline on a secondary QA pins just that QA's api.* calls to
the sim while the rest of the run stays online — handy for keeping test QAs
sandboxed against a live controller. The legacy --%%offline:true is an
alias for --%%mode:offline.
With --api remote, the api table talks to the real HC3: everything your
flua QAs own (devices from 5000, seeded sim state) is served locally, and
anything the sim doesn't know is forwarded to the controller over HTTP —
the same Lua surface, hybrid routing. Remote calls are asynchronous per
QA: a slow controller call suspends only the calling QA (its timers and
events queue, like during fibaro.sleep) while the pump and every other
emulated QA keep running — the response resumes the QA's code exactly where
api.get was called. Offline (the default), calls stay synchronous — the
fast path. Credentials come from the .env chain:
# .env
HC3_URL=http://192.168.1.10/
HC3_USER=admin
HC3_PASSWORD=your-password
HC3_PIN=1111
.venv/bin/flua --api remote script.lua
Remote calls are synchronous (like on the HC3) — each api.get blocks the
QA for the HTTP round trip. Credentials are never retried: a 401/403
stops flua immediately with a warning, because the HC3 locks itself after
4 failed attempts. Wrong credentials = one attempt, then exit 1.
api.hc3.get/post/put/delete always targets the real HC3 directly,
bypassing the hybrid dispatch (this is what fibaro.callhc3 uses, and it's
handy in test code that wants ground-truth data). Offline it stands in for
the simulated HC3.
Proxy mode (--%%mode:proxy)
To test a QA against its real UI in the phone app — or against scenes and
other QAs on the controller — flua can deploy a proxy QuickApp on the
HC3 (the proxy concept). The QA keeps running in the emulator while a
proxy device named <name>_Proxy (same device type, same UI) runs on the
HC3, and the two are treated as one device:
--%%mode:proxy
- The emulated QA runs with the proxy's HC3 id — actions and UI events aimed at the proxy land in the emulator.
- The proxy funnels everything back: device actions are forwarded to
http://<your-ip>:<port>/api/devices/<id>/action/<name>and UI events tohttp://<your-ip>:<port>/api/plugins/callUIEvent; the emulator runs them through the QA'scallAction/UI callbacks exactly like a tap in the real UI. - When the QA updates a property or a UI element (
self:updateProperty,self:updateView,self:setVariable), flua pushes the same update to the HC3, so the proxy always reflects the emulated state. - Property changes produce one refreshStates event: the HC3 proxy emits it and the poller mirrors it back — flua does not generate a second, local event.
- Children:
self:createChildDevicecreates them on the HC3, and children an existing proxy already has are shadowed in the emulator under their HC3 ids at startup — the QA'sinitChildDevices()finds them, their property updates are stored locally and forwarded, and their actions/UI events route back through the proxy like the parent's.api.delete( '/plugins/removeChildDevice/' .. id)deletes a child on the HC3 and drops its emulated shadow (offline it just removes the local child).
An existing <name>_Proxy is reused (duplicates are deleted, the newest
wins; a proxy of the wrong device type is deleted and recreated). On reuse
flua refreshes the proxy's viewLayout/uiView/uiCallbacks from the QA's
current --%%u directives, so UI edits between runs show up on the
controller. useUiView is only pushed when the QA declares
--%%useUiView — otherwise the proxy keeps whatever you configured on the
HC3 (the legacy viewLayout still has features the new uiView lacks), and
a fresh proxy defaults to true. On startup flua tells the proxy where to
listen back through a CONNECT action, so an existing proxy always points
at the current emulator.
Proxy mode only works online: without HC3 credentials flua logs
proxy disabled and runs the QA offline as usual. A proxy QA also keeps
flua alive past idle (like --%%keep-alive:true) — it must stay up to serve
the proxy's callbacks.
The emulator listens for callbacks on all interfaces, port 8080 by default
(FLUA_PROXY_PORT in the .env chain overrides it; busy ports fall back to
the next free one). Your machine and the HC3 must be on the same network,
and your firewall must let the controller reach that port.
Mode comparison
flua picks one of three modes per run: an explicit --api local|remote wins,
then the main QA's directives (--%%mode:..., over any --%%include'd
defaults), then the default — offline (the simulated HC3).
| Aspect | offline |
online |
proxy |
|---|---|---|---|
| Where the QA runs | in the emulator; device registered in the sim (id 5000+) | in the emulator; device registered in the sim (id 5000+) | in the emulator, under the proxy's HC3 id (same deviceID) |
| What exists on the HC3 | nothing | nothing | a <name>_Proxy QuickApp (same type and UI), deployed or reused |
api.* routing |
the sim only; unknown ids 404 | hybrid: sim entities locally, unknown ids forwarded to the HC3 | same hybrid; the QA's own device is the proxy id |
self:updateProperty / updateView / setVariable / internal storage |
sim updated, event posted locally | sim updated, event posted locally | sim AND the HC3 proxy updated; the event is posted by the HC3 — never duplicated locally |
Children (createChildDevice / removeChildDevice) |
created in the sim | created in the sim | created on the HC3 as children of the proxy and shadowed in the sim under their HC3 ids (existing proxy children are shadowed at startup; removal deletes both) |
| UI interactions | the local UI viewer | the local UI viewer | the HC3 phone app, through the proxy (actions and UI events are funnelled back to the emulator); the local viewer works too |
| Reached from the HC3 (scenes, other QAs) | no | no | yes — through the proxy device |
refreshStates feed |
events generated by the sim | sim events + real HC3 events mirrored through the long poll | same, and the proxy's own events arrive from the HC3 |
| House entities (global variables, custom events, rooms, sections, scenes) | created/updated in the emulator | created/updated in the emulator | created/updated in the emulator |
api.hc3.* |
stands in for the sim | the real HC3, directly | the real HC3, directly |
| HC3 credentials | not needed | required (HC3_URL/HC3_USER/HC3_PASSWORD) |
required |
api.get('/devices') serves the union of the emulator's devices
(running QAs plus the seed) and the HC3's devices when online — the
emulated shadow wins on id clashes, so a proxy QA and its HC3 twin (same
deviceID) count once. An offline-pinned QA (--%%mode:offline) always sees
the sim only. Custom events, global variables and the other house entities
always live in the emulator in all three modes.
Testing against the real HC3
Two opt-in suites, gated so a plain pytest never touches the controller
(set HC3_TEST=1 and configure the credentials in the environment):
HC3_TEST=1 .venv/bin/pytest tests/test_hc3_live.py # lifecycle: read-only + upload/call/delete
HC3_TEST=1 .venv/bin/pytest tests/test_hc3_compat.py # shape oracle: sim vs real HC3
The lifecycle suite is production-aware: read-only checks (settings,
devices, globalVariables, refreshStates), one import of a test QA (its
startup, fibaro.call round-trip, and its refreshStates change feed are
verified), then deletion — only the test QA is ever touched. Wrong
credentials cost exactly one attempt (the lockout guard).
The compat suite runs the same requests through the offline sim and the real HC3 and diffs the response shapes: type mismatches and fields the sim serves that the HC3 doesn't are failures; fields the sim doesn't model yet are reported as gaps.
RefreshStateSubscriber (the refreshStates subscriber class) works in
both modes: offline, sim state changes (device creation/removal, property
updates, actions) are emitted as real-shaped DeviceCreatedEvent /
DeviceRemovedEvent / DevicePropertyUpdatedEvent /
DeviceActionRanEvent events — updateProperty only emits when the value
actually changes — and online, the poller long-polls the real HC3's
refreshStates and mirrors its events into the same feed. Both arrive at
subscribers through the message pump as a refreshStateEvent message.
The poller never keeps flua alive by itself: online runs still exit when no timers, messages, or connections are pending. A QA that must keep running past idle — waiting for HC3 events like it would on the controller — opts in with a directive:
--%%keep-alive:true
While a loaded keep-alive QA is running, flua holds the HC3's long poll and
stays up until the QA exits (or Ctrl-C; a fixed --run-for -5 also works);
without the directive the poller still mirrors events for the running QAs,
but never holds the process open — a drained run exits promptly. Ctrl-C
always terminates flua immediately, even mid-long-poll, and exits with
status 130.
--seed loads a simulated house (see examples/house.json):
.venv/bin/flua --seed examples/house.json script.lua
Your QAs become devices (ids from 5000) in the same simulated HC3, so everything works between them:
api.get/post/put/delete— the real HC3 REST surface (devices, plugins, variables, globals, scenes, alarms, profiles, refreshStates, …)fibaro.call(id, 'turnOn')— calls another QA's QuickApp methodfibaro.emitCustomEvent(name)→QuickApp:onCustomEvent(name)handlersself:setVariable/getVariable— persistent, survives restartsplugin.restart(id)— re-runs the QA's code (onInit runs again, like the HC3 restarting the plugin); timers are cancelled and deferred messages droppedGET /refreshStates?last=N— the HC3's polling change feed
QAs find each other by name with fibaro.getIds({type = "quickApp"}) — see
examples/qa3.lua and examples/qa4.lua calling each other.
Loading QAs at runtime
From VS Code you often run one QA — let it bring up the QAs it needs:
local id = _FLUA.loadQAfromFile("examples/qa3.lua") -- annotations parsed
local id2 = _FLUA.loadQAfromString([[ ...inline QA... ]])
local id3 = _FLUA.loadQAfromFile( -- extra directives
"examples/qa3.lua",
{ "var:friend=42", "property:value=true" }
)
fibaro.call(id, "turnOn") -- immediately reachable
The optional second argument is a table of directive strings, applied as if
appended to the loaded QA's --%% header: var/property merge per key
(the QA's own variables survive), u/file append, everything else
overrides. Both functions return the new QA id (or nil, error).
examples/dynamic.lua demos it.
Network clients
Real network, HC3-style APIs, all asynchronous through the pump (callbacks run in your QA, timers keep running while requests are in flight):
net.HTTPClient()→request(url, {options, success, error})—examples/http.lua(options.checkCertificate = falseskips HTTPS certificate verification, like the HC3 — handy for self-signed certs; the default verifies)net.TCPSocket({timeout=ms})→connect/send/read/readUntil/close—examples/tcp.luanet.UDPSocket({broadcast, timeout})→sendTo/receive—examples/udp.luanet.WebSocketClient()/WebSocketClientTls()→addEventListener,connect,send,sendBinary—examples/websocket.lua(connect(url, headers)takes a headers table, like the HC3;sendBinarysends binary frames, anddataReceived(payload, isBinary)receives them — the new HC3 binary mode)mqtt.Client.connect(uri, options)→subscribe/publish/unsubscribe/disconnect,mqtt.QoS—examples/mqtt.lua(mqtts://oroptions.tlsenables TLS:useSNIon by default like the HC3 ≥ 5.191,tls.useSNI = falseswitches SNI off; alsoallowUnauthorized,certificateAuthority,clientCertificate)
TCP/UDP payloads are binary-safe: raw bytes (including NUL and values
above 0x7F) cross the bridge byte-for-byte in both directions — a payload
is escaped for the message bridge and un-escaped on the wire, so even a
literal \x sequence in your data round-trips intact.
Mock servers (net.*Server)
A flua extension (no HC3 counterpart): a QA can host mock servers so its
net.* clients can be tested without a controller or Python. The handler
runs in the QA on every request; its return value is the reply. All servers
bind 127.0.0.1; port 0 picks a free port (server.port).
local http = net.HTTPServer()
http:listen(0, function(req) -- {method,url,headers,body}
return {status = 200, body = "ok", headers = {["X-Mock"] = "1"}} -- or a string, or nil (204)
end)
local tcp = net.TCPServer()
tcp:listen(0, function(data) return data:upper() end) -- first package in, reply out, close
local udp = net.UDPServer()
udp:listen(0, function(data, ip, port) return "got:" .. data end) -- nil = no reply
local ws = net.WebSocketServer()
ws:listen(0, function(message, isBinary) return "echo:" .. message end)
...
http:close()
Semantics are deliberately one-shot (request/response): the TCP handler sees
the first data package the peer sends, the UDP handler one datagram, the WS
handler one message (binary messages echo back as binary frames, byte-safe).
A handler that raises replies 500 (HTTP) or nothing; a handler that never
returns answers 504 after 10 s. See examples/mockServer.lua — a QA that
hosts all four and verifies its own clients against them.
Deploying to the HC3
.venv/bin/flua export script.lua -o myqa.fqa
Upload myqa.fqa through the HC3 web UI (Create QuickApp → import). The
package follows the HC3's schema — only the properties the HC3 accepts
travel; dynamic properties are left out.
Or let flua talk to the HC3 directly with the --tool (alias -t)
commands — they package/unpack for you, so you develop in the unpacked
format (individual .lua files) and only the HC3 sees .fqa:
# upload an unpacked project (main file + --%%file extras) as a new QA
.venv/bin/flua --tool uploadQA script.lua # --room ID to place it
# update an existing QA's files from the unpacked project
.venv/bin/flua --tool updateQA 123 script.lua
# update ONE file in a downloaded QA (the VS Code task uses ${file})
.venv/bin/flua --tool uploadFile MyQA_lib_123.lua
# download a QA from the HC3 and unpack it into individual .lua files
.venv/bin/flua --tool downloadQA 123 -d project/ # -d DIR (default: QA name)
# build an offline db from the real HC3 (device 1, location, family locations)
.venv/bin/flua --tool createDB -o db.json # -f to overwrite
# scaffold a QA project: .vscode configs + agent skills/instructions
.venv/bin/flua --tool setup [project-dir]
# scaffold a QA from a standard device type template (41 types)
.venv/bin/flua --tool newQA binarySwitch "Hall lamp" # lists all types with no argument
# package / unpack QA projects as .fqa files (tool forms of flua export/unpack)
.venv/bin/flua --tool pack main.lua -o myqa.fqa
.venv/bin/flua --tool unpack myqa.fqa -d project/
# deploy a ready-made package as a NEW QA, or download one from the HC3
.venv/bin/flua --tool uploadFQA myqa.fqa
.venv/bin/flua --tool downloadFQA 123
# list the installed tools (a bare --tool, or --tool help)
.venv/bin/flua --tool
A download names every file <QA-name>_<file>_<id>.lua (the QA name is
sanitized for the file system) — My QA_main_123.lua for the main,
My_QA_lib_123.lua for extras — so several QAs can live in one directory
without colliding, and the HC3 id travels with the files. The generated main
regenerates the QA's header from the package: its UI comes back as --%%u
rows (translated from uiView/viewLayout/uiCallbacks), variables as
--%%var lines — and any old --%% directives in the downloaded code are
dropped, since they may be outdated. uploadQA then takes either the QA
name (it finds the project in the current directory) or the fully qualified
<QA-name>_main_<id>.lua and updates that QA on the HC3; a plain
main.lua uploads as a new QA.
downloadQA also stamps every unpacked file with two metadata directives —
--%%deviceId:123 (the HC3 device id) and --%%qaFile:lib (the QA file
name the file maps to). uploadFile reads them, so one file can be pushed
back to its QA without re-uploading the whole project — the VS Code task
"Flua: Upload file to HC3" (installed by --tool setup) runs it on the
file you are editing (${file}). Files downloaded before the directives
existed still work: the <QA-name>_<file>_<id>.lua file name is the
fallback. Updating the main file restarts the QA on the HC3; the tool warns
when the target QA's name no longer matches the file name (a file copied
out of another QA's project).
The tools need HC3 credentials (the .env chain, like online mode).
uploadQA prints the new device id; updateQA puts all files at once;
downloadQA writes a main.lua with the QA's directives plus the extra
files — a normal flua project.
Bringing an HC3 QA home
Export the QA from the HC3 UI (.fqa), then:
.venv/bin/flua unpack myqa.fqa -d myqa-project/
You get a runnable flua project: main.lua with generated --%% directives
and one file per QA file, ready for editing and debugging.
Static checks
.venv/bin/flua --check script.lua
Checks syntax, unknown --%% directives (typos), and deprecated API calls.
Warnings don't fail the run; syntax errors exit 1. Good in CI.
Self-testing a QA (no repo needed)
A QA can be its own test suite — no pytest, no repo. Assert inside the QA, exit with the failure count, and let the caller (a shell, CI, or a coding agent) check the exit code:
.venv/bin/flua --api local examples/selfTest.lua # prints PASS/FAIL lines
echo $? # 0 = pass, non-zero = fail
Instead of hand-rolling the assertions, require("flua-test") — a
built-in assertions library (no files needed, nothing travels in the
.fqa):
local Test = require("flua-test")
local t = Test.new()
t:expect(cond, "msg") -- PASS:/FAIL: line, counts failures
t:assert_eq(got, want, "msg") -- deep compare, tables included
t:assert_json(text, "msg") -- json.decode succeeds
t:finish() -- "failures: N" + exit(failures > 0 and 1 or 0)
Asserts can also TRAVEL to a real HC3 — declare the library as a QA file
and the same code needs no editing: on the controller every assertion
becomes a no-op (no printing, no counting, finish() returns 0), because
the module detects the HC3 by the absence of flua's _FLUA global. In
flua the shipped copy is the full library either way:
--%%file:$flua-test.lua,flua_test -- ships in the .fqa; loads before main
local Test = require("flua-test") -- works on the HC3 too (the file
-- registers itself in package.preload)
-- ...or the shared-file idiom, no require: local t = fluaTest.new()
The exit code is the contract: an uncaught Lua error exits 1, and exit(N)
(os.exit(N)) exits N — the QA must end with an explicit exit, or the run
never finishes. A failing QA in a multi-QA run fails the process (the last
failing QA's code wins), and flua prints a verdict line on stderr —
flua: QA <name> (<id>) exited with code N — so agents can attribute the
exit code.
For machine-readable runs (agents parsing the log), --log json emits one
JSON object per line — {time, level, tag, qaid, msg} — with stdout kept
pure JSON and flua's own notices on stderr; --logfile FILE writes a copy
of the log (ANSI stripped, JSON lines with --log json).
UI behavior folds into the same loop from Lua: _FLUA.uiEvent(element, eventType[, value]) injects a UI event exactly as the HC3 UI would deliver
it (values arrive as strings, like the real UI), and self:getView()
returns the current rendered view — updateView changes included. A text
rendering of the UI without a browser: flua --tool ui script.lua.
When the logic under test needs external interactions, serve the sim API
with --ui PORT and drive it over HTTP (/plugins/callUIEvent,
/devices/<id>/action/<name>, /devices/<id> for state), and keep timer
logic deterministic with --start, --speed, --instant, and --seed.
The quickapp-test skill installed by --tool setup documents the whole
loop for coding agents.
The HC3 REST API reference
docs/specs/hc3-api.json is the checked-in structural reference of the HC3
REST API — endpoints (method, path, parameters with types, request-body
schemas, response status codes) and schema field names/types. It is
distilled from the official swagger, which is Fibaro's property and not
distributed: the local copy (fibaro_api_docs/, gitignored) is only needed
when regenerating the reference:
.venv/bin/python scripts/extract_hc3_api.py
flua extensions (_FLUA)
Everything HC3-compatible is a plain global (QuickApp, fibaro, api,
net, mqtt, json, …) — and like on the HC3 (one Lua state per QA), a
QA's _G is its own environment: _G.x = 1 writes the QA's global
x, invisible to other QAs and the shared runtime. flua-specific helpers
live on _FLUA so your code stays portable:
_FLUA.qaId— this QA's id;_FLUA.config— this QA's config table_FLUA.mode— this QA's effective runtime mode:"offline","online"or"proxy"(vsconfig.mode/config.offline/config.proxy, which are the directives the QA declared — a QA can pin itself offline in an online run)_FLUA.arg— the file path /-emarker_FLUA.exit(code)— stop the engine (vsexit(code)/os.exit()in a QA, which stop only that QA —os.exit()is the name real HC3 QAs use)_FLUA.qa(id)— another QA's QuickApp instance (lupa proxy)_FLUA.loadQAfromFile(path, directives?)/_FLUA.loadQAfromString(code, directives?)— dynamic QA loading;directivesis an optional table of directive strings added to the loaded QA's config_FLUA.async.run(fn)/_FLUA.async.await(worker)/_FLUA.async.wait(ms)— coroutine awaits_FLUA.millitime()— virtual time in whole milliseconds (sub-second timing;os.time()is whole seconds,os.clock()is real CPU time)_FLUA.postRefreshEvent(event)— inject a fake refreshStates event (alarm, weather, device property change, …) to debug reactive QAs: it lands in the sim feed (api.get('/refreshStates')) and reaches everyRefreshStateSubscriber; aDevicePropertyUpdatedEventalso updates the shadowed device.created/sourceTypeare filled in when omitted._FLUA.uiEvent(elementName, eventType[, value])— inject a UI event exactly as the HC3 UI delivers it (the same route the viewer uses), returns(data, status); the QA's callback fires async, values arrive as strings. The verify loop's browser-free UI testing (withself:getView()to read the rendered view)._FLUA.setTimeout(fn, ms, qaId)— timer with explicit QA attributionif _FLUA then— detect flua at runtime
Metadata
Release files for fibaro-flua 0.1.37
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fibaro_flua-0.1.37.tar.gz | 576.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fibaro_flua-0.1.37-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.1 MB
Release files / fibaro_flua-0.1.37.tar.gz
| Download URL | fibaro_flua-0.1.37.tar.gz |
|---|---|
| Size | 576.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
88dcd9add930f2ac4121dbd7df4635fb621e366e95c1173d9ac05d6967a1acea
|
|
BLAKE2b-256 checksum How to use checksums |
341456b2ea7818fd37d0c69dcd984ff57bb8d07d5f7bd3ea05d45c659fc8e39b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / fibaro_flua-0.1.37-py3-none-any.whl
| Download URL | fibaro_flua-0.1.37-py3-none-any.whl |
|---|---|
| Size | 508.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f8cef97c5a0277747f7cf3f3ad08d968bcf6c77ce0d7ff853e80b7e286fad47b
|
|
BLAKE2b-256 checksum How to use checksums |
98d1ee8df555e1a26715f3890b7514dc97812f22dc7610190749b51f4b99932f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|