DLI ISO32 User's Guide

On its own, a power switch isn't very smart. Programmers can easily add custom functionality by using the built-in Lua-based scripting language in power controllers.

Hardware requirements

Lua-based scripting is available in all ISO32 controllers. Beeper, backlight, LCD, voltage and current monitoring functions are limited to products with appropriate hardware installed.


The scripting server has the following configurable parameters:

User script configuration
  • Script step delay — the time in seconds to wait after execution of a legacy API function (e.g. ON, OFF, see below). Modern API functions (see below) don't have internal delays unless documented; the delay() function should be used there.
  • User message timeout — the time in seconds after which user messages (displayed e.g. with the DISPLAY command) disappear even if no keys are pressed on the LCD and no changes have been made to the outlet state (leave empty to have the messages displayed indefinitely).
  • User message force display timeout — the time in seconds during which user messages are displayed even despite keys being pressed on the LCD or changes to the outlet state (leave empty to have the messages forcefully displayed indefinitely).
  • Start on reboot at — the scripting function to start at startup.
  • Handle warm boots and service restarts — call the reboot handler scripting function not only on cold boot, but additionally on warm boot and service restart, and pass it an argument specifying the event it is handling (one of "cold\_boot", "warm\_boot" or "restart").
  • Trace script — enable diagnostic output about script progress to system log.

Entering scripts

First, for a quick overview of the script language visit the sample scripts page on the Digital Loggers web site. Log in as admin and use the Scripting link to access the programming page.

Scripting is based on the Lua programming language. A brief introduction is done below, but you may want to consult the general description, especially if you intend to write more complex scripts.

Script code is organized in functions. Configuration items which allow some scripting reaction to an event (reboot, autoping failure, etc.) will ask you for the name of the function to call (you will be offered a list of the functions defined in the script).

User script source editor

You will need to define your functions to be able to use scripting. Simply putting calls to existing functions in the script will not work. Functions are defined like this:

function my_function()
    ... statements go here ...

All functions defined this way will be usable from the web UI and callable externally. If you want to define a function for internal use, not to be called from outside, prefix it with local:

local function my_internal_function()
    ... statements go here ...

Local functions declared this way must be placed before any function that uses it. If you want to move the definition elsewhere and don't need to call the function before the definition, you can use the following structure:

local my_internal_function

... functions that use my_internal_function in bodies ...

function my_internal_function()
    ... statements go here ...

This works as long as all calls to my_internal_function occur in other functions not immediately executed. my_internal_function is still local (not visible externally). This works because a function is just another type of value, and function fn()...end is just like fn=function()... end, so it works the same way as

local x

Here, x has the value nil until it's assigned the value 5. Similarly, local function x()... end is just like local x; x=function()... end.

Functions in Lua are called with their arguments parenthesized, e.g. func(arg1,arg2). Functions with no arguments are called with empty parentheses, like func(). However, according to Lua syntax, a single string function argument doesn't need parentheses, thus allowing the BASIC-like commands to avoid them if there's only one argument.

You can also create functions with arbitrary names (including Unicode) like this:

_G["Turn on lamp"]=function()
    ... statements go here ...

All the above declarations are essentially assignments to (global and local) variables. Therefore, if multiple scripts functions have the same name, the one closest to the bottom will effectively mask preceding ones.

When you're prompted for a script function to choose, choices will appear in the order they're specified in the script source. If you wish to change that, you can define a global ui_order table with a list of quoted function names to appear at the top of the list, in the desired order (others will be at the bottom of the list in alphabetical order). E.g.


Most APIs listed below are only available when used inside functions, not in the global context. That is, attempts to write e.g.


without an enclosing function will fail (such actions cannot be taken at script load time).


Snippets are script fragments not part of the main script, stored for later use or shipped with the device.

User script snippet editor

The snippet name selector can be used to choose an existing snippet, or to enter a new name, e.g. to create a new snippet or save the current snippet under a different name. Use the "Load" button to load the snippet with the selected name in the snippet editor. Use the "Save" button to save the currently edited snippet under the selected name. The "Remove" button can be used to erase the selected snippet.

Snippet code can be copied/pasted from/to the main script code. It is not subject to syntax or other checks so it's OK to place incomplete code fragments there.

Snippet names starting with 'default.' are reserved for snippets shipped with the device. It is not recommended to change or create snippets with such names because your changes may be erased on an upgrade.


Multiple threads of execution can be running at the same time. Any number of threads may run concurrently.

User script thread list

Threads can be started from the web UI, via an HTTP request, by an AutoPing trigger, or from other threads using thread.run. They can be explicitly stopped using the web UI or by calling thread.kill or thread.killall from the script, or implicitly by calling thread.limit.

Every thread has an 'origin', which is usually a string identifying the function that started the thread. For instance, when you create a function like this:

function my_function()
    ... statements go here ...

and then start it with the web UI, its origin is the "my_function" string. Threads created by other threads inherit their parent's origin, which can be useful when stopping a group of threads.

API levels

The scripting engine presents two sets of functions that you can use to write scripts:

  • Legacy functions — functions which are designed to resemble the BASIC commands of the previous generations of EPCR/LPC controllers;
  • Modern API — functions and objects which are designed to be easier to use.

You can use and even freely mix them as you wish, but only the modern API will receive further development attention. Some features are exposed only via the modern API because they had no corresponding legacy commands.

Legacy functions

Legacy functions (written in CAPS) are executed in sequence with a "step delay" after them. The legacy functions are designed so as to resemble the BASIC commands of the previous generations of EPCR/LPC controllers while remaining compatible with the Lua language.

Arguments to the legacy functions can be written as e.g. ON(12345678), ON "12345678" or ON("12345678").

The supported legacy functions are:

  • ON, OFF, CYCLE, RESTORE — perform the action on a list of outlets by numbers (as a number or a string);
  • BEEP(ON) or BEEP(on) or BEEP(true) — turn beeper on;
  • BEEP(OFF) or BEEP(off) or BEEP(false) or BEEP(0) — turn beeper off;
  • BEEP(number>0) — turn beeper on for the specified number of seconds, then off;
  • SLEEP(number[,"unit"]) — suspend execution for the given amount of time (units default to "seconds", but can be "seconds", "minutes", "hours" or "days"; abbreviations like "sec", "h", "d" are also accepted);
  • WAIT "cron time mask" or WAIT(minute_mask,hour_mask,day_mask,month_mask,weekday_mask) — wait for the local time to match the condition (each separate mask element must be a number or "*", and a "cron time mask" must be a string of 5 such elements separated by whitespace);
  • LOG "String" — write a message to the system log
  • DISPLAY "String" — display a string on the LCD when it's in outlet mode. The following strings are expanded:
    • %% — literal "%";
    • %o — state of outlets, in the form "12456" (ON outlets are listed);
    • %O — state of outlets, in the form "++-+++--";
    • %n — serial number;
    • %f — firmware version;
    • %d — system time/date;
    • %M — MAC address of the power controller;
    • %i — IP address of the power controller;
    • %m — IP network mask;
    • %g — IP gateway;
    • \1 — move cursor to the beginning of line 1;
    • \2 — move cursor to the beginning of line 2;
    • \f — clear screen;
    • \v — clear end of current line;
  • WOL "MAC address" — attempt to wake device with specified MAC address up using Wake-on-LAN protocol (the device has to be in the same LAN segment);
  • TIME "server" — synchronize time with server specified by IP address or hostname in quotes; you can use TIME() without arguments to synchronize with "pool.ntp.org" if the DNS is configured correctly.

You still need to enclose the function contents in a function name() ... end as explained above.

Modern API

Modern API allows more object-oriented approach to scripting. You need to explicitly use the delay() function if you use the modern API and need a delay. Note that the outlet power-on sequence delay applies anyway.

Lua objects can have fields (data contained in the object) and methods (functions which affect the object's state). Object fields are accessed with a dot ., like meter.reading. However, different object implementations in Lua may use the colon : or the dot . to access the object's methods (outlet:cycle() or outlet.cycle()). In the modern API, all objects use the dot . to access their methods, to prevent confusion.

Modern API objects and functions are grouped into several categories for convenience.

Core Lua functions

To make scripting safer, only a limited subset of Lua features is supported by sandboxing. The following Lua standard library features are supported:

Globals: _VERSION, assert, error, next, ipairs, pairs, pcall, xpcall, select, tonumber, tostring, type, unpack.

string library: string.byte, string.char, string.find, string.format, string.gmatch, string.gsub, string.len, string.lower, string.match, string.rep, string.reverse, string.sub, string.upper.

table library: table.insert, table.concat, table.maxn, table.remove, table.sort.

math library: math.abs, math.acos, math.asin, math.atan, math.atan2, math.ceil, math.cos, math.cosh, math.deg, math.exp, math.floor, math.fmod, math.frexp, math.huge, math.ldexp, math.log, math.log10, math.max, math.min, math.modf, math.pi, math.pow, math.rad, math.random, math.sin, math.sinh, math.sqrt, math.tan, math.tanh.

os library: os.clock, os.difftime, os.date, os.time.

Additionally, _G points to the sandbox environment.

Unlike most APIs, core Lua functions are available in the global context.

Delay functions

The delay function accepts the number of seconds to wait as an argument (it is assumed to be the script step delay if not specified). Only a single script thread is active at any given time; switching to a different thread is not performed until you call delay() (or other function possibly introducing a delay). If a scripting thread doesn't call delay() or one of the legacy API functions every now and then, it can't be terminated by thread.kill and will eventually be shut down by the runtime.

The wait_until function can be used to wait for an arbitrary number of conditions on local time. It may have any number of arguments, each named a condition. It waits for one of the conditions to be satisfied, and returns its 1-based index. If several conditions would be satisfied simultaneously, the first match wins.

A condition is a table which may (but does not have to) contain any set of the following keys:

  • year, which stands for the year;
  • month, which stands for month (1 is January);
  • day, which stands for day of month;
  • wday, which stands for day of the week (1 is Sunday, 2 is Monday, 7 is Saturday);
  • yday, which stands for day of the year (1 is January, 1st);
  • hour, which stands for hours;
  • min, which stands for minutes;
  • sec, which stands for seconds;
  • isdst, which stands for the daylight savings time flag (true or false).

The values corresponding to the keys are the actual restrictions, all of which must be met in order for the condition to be satisfied. A simple value (i.e. a number for anything but the DST flag, and true/false for the DST flag) matches only itself. A function (receiving the field's value as argument) matches anything for which it returns true.

Here are some examples of conditions that can be used:

{hour=0,min=0,sec=0} -- matches at midnight;
{hour=7} -- matches anywhere between 7:00:00 and 7:59:59;
{wday=function(d) return d==1 or d==7 end} -- matches weekends.

It is not advised to perform exact matches on seconds since delays of internal operations may be greater than 1 second. It is advised to introduce additional delays to avoid triggering the same match again. The following sample switches outlet 1 on at 8:00 and off at 17:00.

while true do
    local event=wait_until({hour=8,min=0},{hour=17,min=0})
    if event==1 then
    else -- event==2
    delay (120)

We match with minute precision here and wait for 2 minutes to avoid double matching.

Outlet management

The global variable outlet represents a Lua array of outlet objects.

  • outlet[N].on(): switches the outlet on (affects transient state);
  • outlet[N].off(): switches the outlet off (affects transient state);
  • outlet[N].cycle(): cycles the outlet (affects transient state);
  • outlet[N].name: string representing the name of the outlet;
  • outlet[N].persistent_state: boolean representing the persistent requested state of the outlet (writes will affect transient state as well);
  • outlet[N].transient_state: boolean representing the transient requested state of the outlet;
  • outlet[N].physical_state: read-only boolean representing the physical state of the outlet;
  • outlet[N].state: boolean reflecting outlet physical state when read, modifying transient state when written;
  • outlet[N].locked: read-only boolean indicating if the outlet is locked.

The variable name is chosen to match AC products.

Global constants on and off are true and false, respectively, useful to make scripts more readable, like outlet[1].state=on;

Thread management

Several threads can be executed simultaneously in a pseudo-parallel fashion. The global thread table contains these methods:

  • thread.run can be used to start new threads; it accepts the thread function as argument, and returns the identifier of the resulting thread; it can accept a second string parameter to act as a description of the thread running and a third string parameter to redefine the thread origin, which may be useful for thread.killall and thread.limit;
  • thread.kill can be used to stop a thread; it accepts the identifier of the thread as an argument;
  • thread.killall can be used to stop many threads; it accepts the origin of the threads to kill as an argument (without an argument, all threads are killed, including the calling one);
  • thread.limit allows to ensure that no more than the specified number of threads with the same origin are present; its first argument is the maximum number of threads, and the second one is one of the strings "this", "earliest" or "latest", indicating which thread(s) should be killed if their count is above the limit (it's possible to specify an array of values, like {"this","latest"}, instead).

User interface

The global ui table provides means of configuring the LCD display, backlight and beeper.

Functions ui.beep and ui.blink configure the beeper and LCD backlight, respectively. Their first argument should be a string of "1"s and "0"s, which specifies the pattern, and their second argument should be the number of seconds after which the preceding behaviour is restored.

The ui.line table has two elements ui.line[1] and ui.line[2], specifying the custom displayed strings for the LCD rows (or nil for regular operation of said row). This offers more fine-grained control than the DISPLAY command above.

As follows from the above, the number of LCD rows is # ui.line. Use ui.column_count to retrieve the number of LCD columns (usually 16).

Configuration access

The global variable config represents a partial read-only view on the unit configuration (most of them strings):

  • config.serial: unit serial number;
  • config.hostname: unit hostname;
  • config.contact: primary unit contact;
  • config.contacts: a table storing contacts related to the unit in different ways;
  • config.location: physical location of the unit;
  • config.timezone: system time zone;
  • config.hardware_id: unit hardware model;
  • config.oid: object identifier of unit model;
  • config.version: firmware version;
  • config.outlet_label: kind of endpoint manipulated by the unit (Outlet/Relay).

Transient state management

Local and global variables of scripts are shared between threads created in a single script environment but are in general separate between separately loaded script environments. In the example below, if you create several threads using thread_creator, they will all reference the same instances of local_var and global_var:

local local_var=0

function thread_fn()
  while true do

function thread_creator()
  for i=1,10 do

Even if you run thread_creator several times (e.g. from the web UI) without changing source code, all of the threads will share both local_var and global_var.

However, if you change the code and launch thread_creator again, new instances of local_var and global_var will be created; the 10 new threads will be completely separate from the old threads.

This makes handling global functions and variables consistent (e.g. if you don't have a global variable in the script, you won't accidentally trip over it if it was there in a script you loaded several edit iterations earlier), but this default behaviour may or may not be what you want.

To store arbitrary data between script edits, you can create entries in the global sticky table, like this:

sticky["variable"]="some value to save between script edits"

As usual in Lua, for identifier-like keys you can alternatively use the dot syntax:

sticky.variable="some value to save between script edits"

The above example then becomes:

function init_sticky()

function thread_fn()
  while true do

function thread_creator()
  for i=1,10 do

If you overwrite the whole sticky table like this:

function init_sticky()

the changes will only apply from that environment on (threads started in previous environments will be unaffected).

In both cases you'll need to call init_sticky once explicitly before calling thread_creator, which might be inconvenient. Alternatives include setting default values, if they are not nil, on entry to functions that use the values:

function thread_fn()
  sticky.local_var=sticky.local_var or 0
  sticky.global_var=sticky.global_var or 0
  while true do

or writing code that it handles default nil values transparently:

function thread_fn()
  while true do
    sticky.local_var=(sticky.local_var or 0)+1
    sticky.global_var=(sticky.global_var or 0)+1

Variables created this way are not persisted across reboots; see below for those that are.

Externally accessible state management

Local and global variables of scripts are in general not accessible from outside the scripting engine, e.g. they cannot be manipulated from the REST-like API.

You can create entries in the global external table, like this:

external["variable"]="some value"

and have them accessible under /restapi/script/variables/variablename/.

You can only store strings, numbers, booleans or nil into this table at nonempty string indices. Attempts to use nonstring keys, or table or function values, or overwrite the external table, will result in an error:

external={}          -- error
external["x"]=outlet -- error
external[5]=external -- error

The external table otherwise behaves like sticky; in particular, variables created this way are not persisted across reboots; see below for those that are.

Persistent state management

User scripts can read and write state variables which persist across power cycles. This is done by modifying the global persistent table. For example, the following log_boot_count function, if configured to be started at cold boot, can report the current cold boot number (and the ordinal function is a convenience for printing strings like "1st", "2nd", "3rd", etc.).

local special_suffixes,ordinal

function log_boot_count()
  local boot_count=persistent.boot_count or 0
  LOG("This is my "..ordinal(boot_count).." power cycle")


function ordinal(number)
  local d=number%100
  return tostring(number)..((d<11 or d>19) and special_suffixes[d%10] or "th")

Here, the "boot_count" key is used to store the number of boots. Like with ordinary Lua tables, nonexistent keys have nil values; to remove a key from persistent, write nil to it.

Only strings can be used as keys of persistent. Only numbers, strings and booleans can be stored (or nil can be written to erase a value). Some of these limitations may be raised in future.

All keys of persistent can be enumerated if necessary using pairs().

Meter access

The global meter table allows to read the following measured values :

  • meter.values: a table storing properties of different values measured by meters.

Keys of meter.values are unique human-readable but possibly platform-specific identifiers (e.g. power_voltage), and values are tables with the following form:

  • meter.values[key].name: human-readable name of the measured value;
  • meter.values[key].quantity: kind of the physical quantity of the value (e.g. "current", "voltage", etc.);
  • meter.values[key].value: number representing the current value measured by the meter in standard units;
  • meter.values[key].custom: boolean indicating if the value is custom (user-defined);
  • meter.values[key].bus: always nil for ISO32;
  • meter.values[key].internal: boolean indicating if the value is internal (and should not appear in the web UI);
  • meter.values[key].get_history: function which can be called to obtain historical data points.

The get_history() function must be called like this: get_history(desired_start_time, desired_end_time, desired_step). The desired_start_time is the timestamp of the start of the desired interval (in seconds since the epoch, 1970-01-01T00:00:00Z). Similarly, desired_end_time is the desired end time. desired_step is the desired step between adjacent time points. Resulting data may not match the requirements exactly, but will be generated so that it overlaps the desired time range and has the closest time step. The function returns a table with 3 elements {actual_start, actual_step, data} (note that it doesn't return 3 values, which is possible for Lua functions, but a single table containing them). actual_start is the timestamp of the start of the output interval, actual_step is the step between adjacent time points, and data is a table whose ith element is the (averaged) value of the meter around time actual_start+(i-1)*actual_step (mind that indices in Lua are 1-based) or false if no reading was obtained within that time interval (e.g. if the device was offline).

Its use is best illustrated by an example. Suppose temperature is a value of the form:

local temperature=meter.values[...]

Then the following script could be used for obtaining bounds for the 5-minute-average temperature for the last 24 hours:

local now=os.time()
local history_data=temperature.get_history(now-86400,now,300)
local start,step,data=unpack(history_data)
-- or local start,step,data=history_data[1],history_data[2],history_data[3]
local mintime,mintemp,maxtime,maxtemp
for pos,temp in ipairs(data) do
  if temp then
    local time=start+(pos-1)*step
    if mintemp==nil or mintemp>temp then
    if maxtemp==nil or maxtemp<temp then
if mintime then
  LOG(string.format("Min temperature was %gK at %s",mintemp,os.date("%H:%M:%S",mintime)))
  LOG(string.format("Max temperature was %gK at %s",maxtemp,os.date("%H:%M:%S",maxtime)))
  LOG("No temperature readings for the last 24 hours!")

Note that values of meter.values[key].value and meter.values[key].get_history() are in standard SI units, e.g. degrees Kelvin for temperature.

AutoPing integration

The global autoping table allows to query and configure AutoPing.

  • autoping.enabled: boolean variable which allows enabling (true) or disabling (false) AutoPing;
  • autoping.items[N].enabled: read-only boolean value indicating if the Nth AutoPing item is enabled;
  • autoping.items[N].enable: function to call to attempt to enable (with the argument true) or disable (with the argument false) the Nth AutoPing;
  • autoping.items[N].addresses: array of hostnames or IP addresses of the Nth AutoPing item's elements;
  • autoping.items[N].outlets: array of outlets controlled by the Nth AutoPing item; the field name is chosen to match AC products;
  • autoping.items[N].script: the name of the scripting function run by the Nth AutoPing item when it's triggered ("" to cycle the controlled outlets);
  • autoping.items[N].status: the run-time status of the Nth AutoPing item;
  • autoping.ping_interval: ping interval;
  • autoping.ping_timeout: ping timeout;
  • autoping.post_reboot_delay: post-reboot delay;
  • autoping.max_reboot_count: maximum total reboot count;
  • autoping.max_consecutive_reboot_count: maximum consecutive reboot count;
  • autoping.pings_before_enabling: pings before enabling;
  • autoping.resume_without_retrial: activate enabled entries immediately when service is restored;
  • autoping.handle_failures_immediately: handle explicit failures immediately instead of waiting for timeout.

Network state access

The global network table has two similarly-structured members, network.wired and network.wireless, which represent partial read-only views on state and configuration of the respective networks (all fields are strings unless indicated otherwise):

  • network.(wired|wireless).online: boolean value indicating if the interface is online;
  • network.(wired|wireless).protocol: method for obtaining the IP address (static/dhcp);
  • network.(wired|wireless).ip_address: IP address;
  • network.(wired|wireless).netmask: network mask;
  • network.(wired|wireless).gateway: default gateway;
  • network.(wired|wireless).metric: metric (cost associated with sending data over the interface);
  • network.(wired|wireless).mac_address: MAC address;
  • network.wireless.enabled: boolean value indicating if the wireless interface is enabled;
  • network.wireless.mode: wireless module mode;
  • network.wireless.ssid: wireless network name;
  • network.wireless.channel: wireless channel (channel number as string, e.g. "11", or "auto");
  • network.wireless.encryption: wireless encryption (none/psk/psk-mixed/psk2).

network.wireless may be nil if wireless support is absent.

Event APIs

The global event table allows limited integration of user scripts with the notification subsystem.

The event.send function can be used to send a dli.script.script_event event with custom message and data. It takes the event properties script_message and script_data as arguments. script_message must be a string that will be included in the event message. script_data, if supplied, must be a table with string keys and scalar (boolean, number or string) values, and can be analyzed by the rule conditions and/or used by rule actions. Non-string keys or non-scalar values are ignored; if the script_data argument is not a table, an empty table is sent. All script-generated events have a (possibly empty) table as script_data, and only they have it, so its existence is a distinctive feature of script-generated events.

For example, the following code

event.send("coil 1 energized",{coil_index=1,coil_state=true})

will send an event that can be matched by, among others, the following notification rules:

id=="dli.script.script_event" and script_data.coil_index==1

script_data and script_data.coil_state

All script-generated events have INFO severity level.

Events can also be waited for and received, and that is not limited to system-level events discussed above. An event can have several components with integer indices (the components themselves can be any types, e.g. tables). Events generated by the API generally have two components, a numeric timestamp (in seconds since the epoch, 1970-01-01T00:00:00Z) and a property table, but totally custom events are possible.

It is usually essential not to miss an event when processing them, so events are placed into queues waiting to be processed. The event.stream function is a Lua generator to be used in a for loop like this:

for queue_idx,value1,value2,value3... in event.stream(queue1,queue2,queue3...) do

event.stream accepts any number of queue arguments and waits for events to be received (causing a delay and allowing other threads to run), then extracts and returns their components. Queues are processed in priority order (e.g. queue1 messages get processed before queue2 messages).

The event.queue function creates a totally custom event queue not bound to any event source. Additional custom events can be placed into any queue using q[# q+1]={value1,value2,value3...} regardless of the method used to create the queue; for queues created by event.queue, this is also the only way for events to appear there.

The event.listener function creates an event queue listening to the global system events (i.e. the ones received by the notification system and generated by other systems and event.send function). Events have a timestamp and a property table as components. The property table contains event-type-dependent properties (e.g. the id is the event type identifier).

The following example function can be used to display all system events in internal form in the event log:

function dump_system_events()
  for i,t,data in event.stream(event.listener()) do

The event.change_listener function takes any number of API objects object1,object2,object3... as arguments and creates an event queue listening to changes in the API objects' states. Events have a timestamp and a property table as components; the property table contains the following fields:

  • object: the changed object;
  • index: the object's number in the argument list;
  • key: the property of the object that has changed;
  • value: the new value of the property.

Only a subset of the API's objects support change notifications; they include outlet objects outlet[i] , meter values meter.values[k] and AutoPing items autoping.items[i]. Additionally, objects may not receive change notifications for properties you haven't previously read or written.

The following example function can be used to attempt to mirror physical state of outlet 1 to outlet 2:

function mirror_outlet_1_to_2()
  for i,t,data in event.stream(event.change_listener(outlet[1])) do
    if data.key=="physical_state" then

The event.timeout function accepts a single timeout value and returns an event queue that receives a single event timeout seconds after the function is called. The event has a timestamp and an empty property table.

The event.local_time and event.utc_time functions take any number of time-matching condition tables as arguments and creates an event queue where events appear in time moments matching the conditions. This can be seen as an improved wait_until function which doesn't miss triggers if handling the event takes too long. The most important difference is that new checks are "edge-triggered", not "level-triggered": an event is placed into the queue only when the condition becomes true; no events are created when the condition stays true until it becomes false and then true again. For example, if you start a loop waiting for {hour=7,min=10} at 7:10, the event will not be created right away and the loop will not be executed until the next day, whereas wait_until would return immediately. This is because a "level-triggered" approach might have to add an infinite number of events to the queue. This also means that you usually won't have to add e.g. min=0,sec=0' or similar to the condition to make it start only at the beginning of an hour. Additionally, wait_until only handles local time, which corresponds to event.local_time behaviour, but you can match UTC time with event.utc_time, which has no corresponding wait_until option. Events have a timestamp and a property table as components; the property table contains the following fields:

  • index: the condition's number in the argument list;
  • time: the time matching the condition (not necessarily exactly one of the timestamp).

The following example function can be used to check outlet 1 state and report if it remains physically off for more than an hour. Note that we do not simply poll the outlet e.g. every minute, but subscribe to state change events instead so that we don't miss any changes. We also create the change event queue before the initial state check so that we don't miss any state changes during initialization.

function monitor_1()
  local reported
  local off_since
  local changes=event.change_listener(outlet[1])
  if not outlet[1].physical_state then
    off_since=os.time() -- We don't know that for sure but that's when we start monitoring
  for i,t,data in event.stream(changes,event.utc_time({sec=0})) do
    if i==2 then
      if off_since and t-off_since>3600 then
        if not reported then
          log.warning("Off for too long, since %s",os.date("%c",off_since))
    elseif data.key=="physical_state" then
      if data.value==true then
        if reported then
          log.notice("On again, phew")
      elseif off_since==nil then

Of course we could have used e.g. event.send instead of log, or flipped another outlet, etc.

You should not usually poll the same queue from different threads. Every event from a queue will be processed by a single thread only. A single event can be placed into multiple queues though.

Like any other loop, a loop over event.stream(...) can be terminated via a break statement in the body. The catch is that the loop body isn't executed until a matching event occurs. If you need to terminate a thread's event loop from another thread, you may e.g. create and share an event queue between them, and place an event into it when you want to terminate the loop, like this:

local function event_thread(cancel_queue)
  for i,t,data in event.stream(cancel_queue,...) do
    if i==1 then

function caller_thread()
  local cancel_queue=event.queue()
  thread.run(function() return event_thread(cancel_queue) end)

There are obviously other ways to share a queue like this. The location of cancel_queue in the event.stream argument list above affects whether the loop is terminated immediately after receiving the cancellation message or after processing outstanding events in other queues.

You can also just use thread.kill if you don't need the looping thread at all any longer.

OS-level functions

For security reasons, the scripting engine does not provide access to OS-level functions, such as the while of the unit's filesystem, or process management, by default. Much of the useful functionality described in this section is filtered by the /etc/relay_script_spawn_wrapper script which is run as a wrapper for any command spawned with process.spawn, and it defaults to ignore the command and generate a diagnostic log message. Editing that script (for which you would need to enable SSH from keypad or from web UI) is required to be able to start new processes. In the most permissive configuration, it may grant anyone with administrative rights to execute of arbitrary commands using the user script engine, which may not be desirable.

The global process table provides facilities for managing OS-level processes on the unit.

The process.spawn([attributes,]command,arguments...) method allows start a new process via /etc/relay_script_spawn_wrapper. The command and any arguments must be strings. No shell escaping is necessary. The optional attributes table (considered empty if not supplied) may contain stdin, stdout and/or stderr keys which control the corresponding file descriptors for the child process: each of them may either be:

  • nil, in which case the child process receives /dev/null for that descriptor; or
  • a readable (for stdin) or writable (for stdout and stderr) end of a pipe (unidirectional stream communication channel), in which case that descriptor is received by the child process; or
  • the string "pipe", in which case process.spawn creates a new pipe, supplies the "matching" (as above) end of the pipe to the child process, and returns the "other" (writable for stdin or readable for stdout and stderr) end in the corresponding field of the returned table.

Additionally, the attributes table may contain an env field whose value must be a table with the OS environment variables to be supplied to the spawned process (keys must be strings corresponding to the names of environment variables, and values must be strings, numbers or booleans and will be converted to strings using tostring to be used as values for those environment variables).

If process creation succeeds, a table with the following fields is returned:

  • pid contains the numeric OS-level process identifier (PID), a positive integer;
  • stdin, stdout and/or stderr contain the ends of pipes that can be used to interact with the process, if the corresponding attributes field was set to "pipe".

Additionally, the returned table acts as an event queue'; when the created processis terminated, an event containing the timestamp and the exit reason table is placed into the queue. The exit reason table contains the following fields:

  • code: integer, the exit code of the process computed with shell practice (its exit status if it exited in a regular way, or 128 + signal number if terminated by a signal);
  • status: optional integer, exit status (nil if terminated by a signal);
  • signal: optional integer, received signal (nil if exited in a regular way).

The process.pipe() function can be used to create a pipe and returns two file descriptors, the first one being the read end and the second being the write end. The pipe ends may be supplied to process.spawn() or used for string input/output (see below).

The process.kill(pid[,signalname]) function can be used to send a signal to a process created by process.spawn(), referenced by its pid, which often results in termination of that process. signalname, if supplied, must be one of the following string values indicating the signal to be sent:

  • "term": the SIGTERM signal, requesting graceful shutdown and usually resulting in process termination;
  • "kill": the SIGKILL signal, unconditionally terminating the process without any cleanups;
  • "hup": the SIGHUP signal, indicating "controlling terminal detached" condition and sometimes used as a setting reload request by convention;
  • "int": the SIGINT signal, indicating interrupt from keyboard (as if Ctrl+C were pressed) and usually resulting in process termination;
  • "quit": the SIGQUIT signal, indicating quit from keyboard (as if Ctrl+\\ were pressed) and usually resulting in process termination;
  • "alrm": the SIGALRM signal, indicating timeout and usually resulting in process termination;
  • "stop": the SIGSTOP signal, unconditionally pausing process execution;
  • "cont": the SIGCONT signal, unconditionally resuming execution of a process paused by SIGSTOP;
  • "usr1" or "usr2": the SIGUSR1 or SIGUSR2 signals, which can be handled by the process in an application-specific way but but result in process termination by default.

process.kill() cannot send a signal to a process that hasn't been created by process.spawn(). You may set up /etc/relay_script_spawn_wrapper in a way to execute the native kill command for that.

The process.kill_group(pid[,signalname]) acts like process.kill(pid,signalname), but sends the signal not only to pid but to the whole process group of which pid is the leader (mostly useful to terminate a script that starts other processes if you use chpst -P to create a new process group). Note that no process.* function accepts a negative pid.

Note that a process created by process.spawn() can outlive the creating thread.

The global file table provides facilities for interacting with file descriptors:

  • file.read(f,count): reads and returns a binary string of at most count bytes from f, which must be readable; fewer bytes may be returned; it returns an empty string if an end-of-file condition is met, and nil if some other error occurs.
  • file.write(f,data): writes a binary string data to f, which must be writable and returns the number of bytes written; fewer bytes than #data may be written, so the caller may need to call file.write() again with the remaining data; it returns nil if some error occurs.
  • file.close(f): closes f so that no other operations can be performed on it; f is no longer considered readable or writable by file.read() or file.write(); subsequent file.close() calls do not fail but have no effect.

Note that currently all created file descriptors are, in one way or another, pipes (created by process.pipe() or process.spawn()). You use /etc/relay_script_spawn_wrapper to allow certain commands (e.g. cat or dd) to interact with the underlying filesystem, possibly in a restricted way.

The /storage directory is reserved for the unit administrator and is persisted across upgrades. It may be useful to store scripts to be launched via process.spawn() or other means or data manipulated by such scripts.

The chpst utility may be useful in /etc/relay_script_spawn_wrapper to restrict the spawned process permissions and/or resource use.

You may need to set up the environment to have some commands (e.g. ssh) behave as though you launched them from the command line. You can set them inside /etc/relay_script_spawn_wrapper (e.g. export HOME=/root) and/or in the env field of attributes (e.g. process.spawn({env={HOME="/root"},...},...). The set shell builtin can be used to figure out current environment values.

Utility functions

The global util table provides helpful utility constants and functions which usually have no state or side effects.

util.hex, util.base64, util.url are tables with encode and decode members which perform hex, Base64 and URL component encoding and decoding, respectively; they both accept and return strings.

The util.json table also contains encode and decode members, which perform JSON encoding and decoding, respectively. util.json.encode accepts any regular value (without cycles) and returns a string; util.json.decode accepts a string and returns a value corresponding to it.

Due to how Lua handles tables, an empty JSON array [] cannot be distinguished from an empty JSON object {} once decoded. To help that, util.json.encode accepts an optional second argument, which is an "empty array table", that is, a table with keys corresponding to empty Lua tables in the input which represent empty JSON arrays. For example,

local data={a={},b={}}



Conversely, util.json.decode returns an empty array table as a second value if it is nonempty (that is, if there were empty arrays in the input).

local value,is_empty_array=util.json.decode('{"a":{},"b":[]}',is_empty_array)
log.notice("a: %s%s",type(value.a),is_empty_array[value.a] and ":array" or "")
log.notice("b: %s%s",type(value.b),is_empty_array[value.b] and ":array" or "")

Thus JSON round-trips, i.e. util.json.encode(util.json.decode(JSON)) produces a JSON string equivalent to the input JSON even in the presence of empty arrays/objects.

All transcoding functions may throw errors on invalid input; using pcall / xpcall is advised.

util.null is the JSON null constant, distinct from, but sometimes interchangeable with, nil. It may be useful when dealing with some APIs.

The util.weak_key_table(), util.weak_value_table() and util.weak_key_value_table() functions construct tables with weak keys and/or values as if by using setmetatable({},{__mode="k"}), setmetatable({},{__mode="v"}) and setmetatable({},{__mode="kv"}), respectively.

The util.copy(val[,deep_keys]) function returns a deep plain copy of its argument (which can be or contain an API structure). This may be helpful when working with util.json.encode as it doesn't by itself support encoding API structures. An optional argument, deep_keys, can make util.copy perform deep copying of table keys (in case they're tables themselves), otherwise table keys are left intact.

The util.equal(val1,val2) function performs a deep equality comparison of its two arguments (each of which can be or contain an API structure) and returns true if they are equal and false otherwise. Tables are compared structurally, i.e. they are considered equal if they have equal keys with equal values. Unlike util.copy, util.equal doesn't support comparisons with copied table keys as those are in general undecidable without additional information (consider {[{}]=1,[{}]=2} and {[{}]=2,[{}]=1}).

The util.argpack function compresses its arguments into a table with an extra n member indicating the number of arguments. It could be implemented as:

util.argpack = function(...)
    return {n=select("#",...),...}

The util.argunpack function expands a table into a value list with the number of elements taken from the table's n member. It could be implemented as:

    return unpack(tbl,1,tbl.n)

In combination, util.argpack and util.argunpack allow perfect function argument forwarding in presence of nil arguments.

Unlike most APIs, utility functions are available in the global context.


  • dump — useful debugging function which outputs the argument to the system log, can be used to inspect state and even study the modern API itself (try dump(_G)!). Note that this function may delay execution while dumping sufficiently large objects. Other threads may run while execution of the dumping thread is suspended. If they modify the object being dumped, the resulting dump output may be inconsistent.
  • log — contains methods debug, info, notice, etc. which accept a format string argument and any extra arguments it requires, format the string using string.format internally and log it at the corresponding severity level, e.g.

    local i=5 local name="Fridge" log.notice("Switching on %d (%s)",i,name)

Starting scripts

There are a few ways to start scripts:

  • On power up. This feature automatically starts a specified script function when power is first applied. The default is not to start any function, so pressing the "reset to defaults" button will disable this feature.
  • By another thread. One thread can create another by using the thread.run function. For example, thread.run(func1) creates a new thread that starts executing the func1 function. The execution of the parent thread continues.
  • By issuing an HTTP request. Follow a link http://Your_IP/script.cgi?run=func to start execution from function func. This can be conveniently used by the end users by assigning the programmable web links on the left side of the page a target of the form script.cgi?run=func.
  • Via AutoPing. The AutoPing system can be configured to automatically start execution when IP connectivity is lost. Select the script to run from the selection box to the right of the corresponding IP on the AutoPing page.
  • By manually clicking the Start button. Execution will start with the selected function.

Editing scripts

You don't need to disable scripting before editing scripts. If you make a syntactic error, the script won't be modified. Instead, you'll receive an explanatory message pointing to the error.

If you modify a running script, existing threads will continue to run with the code and environment that existed when the script was started. New threads won't interact with old ones directly. Use transient and persistent state APIs for that.

Stopping a thread

A thread terminates automatically when the end of its outermost function is reached. Click "Stop all running threads" to stop everything. You can also stop all scripts via HTTP using http://Your_IP/script.cgi?stop.

Relay debounce warning

Even with the scripting step delays, it is possible to create a script which will rapidly cycle a relay. This rapid cycling could result in a over current condition, tripped breaker, or stress to the power controller or attached equipment. Please be reasonable!