Lua Runtime

Lua is the primary user-authored orchestration layer in HomeCmdr. Scenes, automations, and shared script modules are all written in Lua 5.4 (vendored via mlua — no system Lua dependency required).

Asset roots:

DirectoryPurpose
config/scenes/Manual user-invoked flows
config/automations/Trigger-driven decisions
config/scripts/Reusable helper modules

Shared Host API (ctx)

Both scenes and automations receive the same host API through a ctx object passed to their execute function.

ctx:command(device_id, command_table)

Sends one canonical command to one device.

ctx:command("elgato_lights:light:0", {
  capability = "brightness",
  action = "set",
  value = 50,
})

The command table maps to the same shape used by POST /devices/{id}/command.

ctx:command_group(group_id, command_table)

Fans out one command to every device in a group, in membership order.

ctx:command_group("bedroom_lamps", {
  capability = "power",
  action = "off",
})

Returns a Lua error immediately if the group does not exist. Empty groups are a silent no-op.

ctx:invoke(target, payload_table)

Dispatches a service-style call to the adapter that owns the target prefix. Returns a Lua value.

local result = ctx:invoke("ollama:chat", {
  messages = {
    { role = "user", content = "Give me a short home summary." },
  },
})
local reply = result.message.content

Invoke targets are adapter-defined. See Ollama for available ollama:* targets.

State read helpers

Lua assets can inspect the live registry without going through HTTP:

MethodReturns
ctx:get_device(device_id)Device table or nil
ctx:list_devices()Array of device tables
ctx:get_room(room_id)Room table or nil
ctx:list_rooms()Array of room tables
ctx:list_room_devices(room_id)Devices assigned to a room
ctx:get_group(group_id)Group table or nil
ctx:list_groups()Array of group tables
ctx:list_group_devices(group_id)Devices in a group

Device tables include: id, room_id, kind, attributes, metadata, updated_at, last_seen.

Group tables include: id, name, members (array of device IDs).

ctx:log(level, message, fields?)

Emits a structured log line from within a scene or automation.

ctx:log("info", "rain automation fired", {
  automation_id = "rain_reminder",
  device_id = event.device_id,
})

ctx:sleep(secs)

Pauses Lua execution for the given number of seconds without blocking the async executor.

ctx:command_group("bedroom_lamps", { capability = "power", action = "off" })
ctx:sleep(5)
ctx:command("roku_tv:tv", { capability = "power", action = "off" })
  • Valid range: 0..=3600. Values outside this range produce a Lua error.
  • Sleep is not interruptible by restart mode — the cancellation takes effect after the sleep returns.
  • For scheduling that does not require in-script delays, prefer trigger-level primitives (interval, wall_clock, cron, debounce_secs) over ctx:sleep.

Scenes

Scenes are manually invoked by users or external clients via POST /scenes/{id}/execute.

Location: config/scenes/*.lua

Minimum contract:

return {
  id = "video",
  name = "Video",
  description = "Prepare devices for a video call",
  execute = function(ctx)
    ctx:command("roku_tv:tv", {
      capability = "power",
      action = "off",
    })
    ctx:command("elgato_lights:light:0", {
      capability = "power",
      action = "on",
    })
  end
}
FieldRequiredDescription
idyesStable unique identifier
nameyesDisplay name
descriptionnoOptional description
executeyesfunction(ctx) — the scene body
modenoExecution mode (see below)

Duplicate scene IDs fail startup. Scene files must return a Lua table.


Automations

Automations are triggered by runtime events and optionally filtered by conditions.

Location: config/automations/*.lua

Minimum contract:

return {
  id = "rain_check",
  name = "Rain Check",
  trigger = {
    type = "device_state_change",
    device_id = "weather:outside",
    attribute = "rain",
    equals = true,
  },
  execute = function(ctx, event)
    ctx:log("info", "it's raining", { device_id = event.device_id })
  end,
}
FieldRequiredDescription
idyesStable unique identifier
nameyesDisplay name
triggeryesTrigger definition (see below)
executeyesfunction(ctx, event) — the automation body
descriptionnoOptional description
conditionsnoOptional list of AND conditions
statenoCooldown / dedupe / resumable schedule
modenoExecution mode

Trigger Types

device_state_change

Fires when a device attribute changes.

trigger = {
  type = "device_state_change",
  device_id = "weather:outside",
  attribute = "rain",
  equals = true,
}
FieldDescription
device_idRequired
attributeOptional — filter to one attribute
equalsOptional exact-match value
above / belowOptional numeric thresholds; fire when the value crosses into range
debounce_secsOptional — wait for value to remain stable before firing
duration_secsOptional — wait for value to remain true for the full duration

weather_state

Same matching rules as device_state_change, but semantically scoped to weather/environmental devices.

Fields: device_id (required), attribute (required), equals, above, below, debounce_secs, duration_secs.

adapter_lifecycle

Fires when an adapter starts.

trigger = {
  type = "adapter_lifecycle",
  adapter = "elgato_lights",   -- omit to match any adapter
  event = "started",
}

system_error

Fires on a runtime error.

trigger = {
  type = "system_error",
  contains = "poll failed",   -- optional substring match
}

wall_clock

Fires once per day at a specific local time (uses locale.timezone).

trigger = {
  type = "wall_clock",
  hour = 6,
  minute = 30,
}

cron

Fires on a UTC seven-field cron schedule (seconds supported).

trigger = {
  type = "cron",
  expression = "0 */5 * * * * *",
}

sunrise / sunset

Fires at sunrise or sunset for the configured location (locale.latitude, locale.longitude).

trigger = {
  type = "sunset",
  offset_mins = -15,  -- 15 minutes before sunset
}

interval

Fires on a repeating interval.

trigger = {
  type = "interval",
  every_secs = 3600,
}

every_secs must be greater than zero.


Conditions

Conditions are optional filters evaluated after the trigger matches. All conditions use AND logic.

conditions = {
  {
    type = "time_window",
    start = "18:00",
    end = "23:00",
  },
  {
    type = "sun_position",
    after = "sunset",
  },
  {
    type = "device_state",
    device_id = "roku_tv:tv",
    attribute = "power",
    equals = false,
  },
},

device_state

Fields: device_id (required), attribute (required), equals, above, below.

presence

Fields: device_id (required), attribute (default: presence), equals (default: true).

time_window

Fields: start and end in HH:MM format. Evaluated in locale.timezone. Supports overnight ranges (e.g. 22:00 to 06:00).

room_state

Fields: room_id (required), min_devices (optional), max_devices (optional).

sun_position

Fields: after (sunrise or sunset), before (sunrise or sunset), after_offset_mins, before_offset_mins. Uses configured latitude/longitude.


Execution Modes

Both scenes and automations support an optional mode field that controls concurrent execution behavior.

ModeBehaviour
parallel (default)Up to max concurrent executions; additional triggers are dropped
singleAt most one execution at a time; concurrent triggers are dropped
queuedOne at a time; up to max pending triggers queued in order
restartCancels the running execution and starts fresh immediately

The default max for parallel mode is automations.runner.default_max_concurrent in config/default.toml (defaults to 8).

String shorthand (for modes without a meaningful max):

mode = "single"
mode = "restart"

Table form with optional max:

mode = { type = "parallel", max = 3 }
mode = { type = "queued", max = 10 }

Scripts

Scripts are reusable Lua helper modules loaded with require(...) from scenes and automations.

Location: config/scripts/*.lua

-- config/scripts/ollama.lua
local M = {}

function M.vision_bool(ctx, prompt, image_base64)
  local result = ctx:invoke("ollama:vision", {
    prompt = prompt,
    image_base64 = image_base64,
  })
  return result.boolean == true
end

return M

Usage in a scene or automation:

local ollama = require("ollama")

if ollama.vision_bool(ctx, "Reply only true or false. Are clothes on the line?", snapshot) then
  ctx:command("elgato_lights:light:0", { capability = "power", action = "on" })
end

Namespaced modules are also supported:

require("lighting.helpers")  -- loads config/scripts/lighting/helpers.lua

Rules:

  • Module names must stay within config/scripts/.
  • Modules are cached within a single execution via Lua's built-in require cache.
  • Each new scene/automation execution uses a fresh Lua state, so script edits take effect on the next execution.

Persisted Runtime State

Automations can declare runtime state policy with a top-level state table:

state = {
  cooldown_secs = 300,
  dedupe_window_secs = 60,
  resumable_schedule = true,
}
FieldDescription
cooldown_secsSuppress re-trigger for this many seconds after a successful execution
dedupe_window_secsSuppress identical trigger payloads within this window
resumable_schedulePersist the last scheduled fire time so the next occurrence resumes after a restart

Reload Workflow

Scenes and automations support manual catalog reload without restarting the process:

curl -X POST http://127.0.0.1:3000/scenes/reload
curl -X POST http://127.0.0.1:3000/automations/reload
curl -X POST http://127.0.0.1:3000/scripts/reload

Behavior:

  • Validates all files before activation.
  • Atomically swaps the active catalog on success.
  • Keeps the previous catalog if any file fails validation.
  • In-flight executions continue on their current Lua context.

Use reload when you change catalog-level fields: IDs, names, mode, trigger, or conditions.

Optional Watch Mode

config/default.toml supports optional file-watch flags:

[scenes]
watch = false

[automations]
watch = false

[scripts]
watch = false

When enabled, saving .lua files in the relevant directory triggers a debounced reload using the same validation pipeline.

Recommended: keep watch = false in production and use manual reload endpoints as the default operational flow.


Event Object

The event table passed to execute(ctx, event) includes:

FieldDescription
event.typeTrigger type string
event.device_idSet for device and weather triggers
event.attributeSet when filtered by attribute
event.valueCurrent attribute value
event.previous_valuePrevious attribute value
event.attributesFull attribute snapshot
event.scheduled_atSet for time-based triggers

Configuration Reference

[locale]
timezone = "Europe/London"
latitude = 51.5
longitude = -0.1

[scenes]
enabled = true
directory = "config/scenes"
watch = false

[automations]
enabled = true
directory = "config/automations"
watch = false

[automations.runner]
default_max_concurrent = 8
backstop_timeout_secs = 3600

[scripts]
enabled = true
directory = "config/scripts"
watch = false