Skip to content

Lua: Shell Integration

Tin Švagelj edited this page Apr 17, 2026 · 1 revision

Shell Integration

Conky provides many built-in variables for system data, but sometimes you need information from external commands — disk usage details, package counts, custom sensor readings, or data from web APIs.

Lua's io.popen() function runs shell commands and captures their output. This page covers how to use it effectively in Conky, including timed execution and parsing command output.

Running Commands

io.popen() runs a shell command and returns a file handle for reading its output:

local handle = io.popen("df -h")
local output = handle:read("*a")  -- read all output as a single string
handle:close()

Warning

Always call handle:close() on file handles to avoid resource leaks.

Timed Execution

Commands placed directly in a draw hook run on every Conky update — which can mean every second. For anything more expensive than a trivial command, this is wasteful and can cause noticeable lag.

The solution is a timer based on Conky's ${updates} variable, which counts how many times the window has been redrawn. Using the modulo operator, you can run code only every N-th update:

local updates = tonumber(conky_parse("${updates}"))
local interval = 30  -- every 30th update

if updates % interval == 0 then
    -- runs once every 30 updates
    local handle = io.popen("df -h")
    cached_output = handle:read("*a")
    handle:close()
end

-- use cached_output for drawing (available every update)

The result is stored in a global variable (cached_output) so it remains available on updates where the command doesn't run. This is analogous to ${execi 30 command} in conky.text.

Caution

The interval is measured in update cycles, not seconds. It only corresponds to seconds if update_interval is set to 1. With update_interval = 2, an interval of 30 means every 60 seconds.

Note

update_interval is a goal, not guaranteed to be precise. Each update cycle includes Conky's own internal processing, so the actual interval is always slightly longer than configured. The drift is small (sub-second) but accumulates over time. Use time-based intervals if precision matters.

Time-Based Intervals

For cases where precise real-time intervals matter (e.g. polling an API exactly every 5 minutes), use os.time() instead of the update counter:

last_run = last_run or 0
local interval_seconds = 300  -- 5 minutes

function conky_main()
    if conky_window == nil then return end

    local now = os.time()
    if now - last_run >= interval_seconds then
        local handle = io.popen("curl -s 'https://api.example.com/data'")
        cached_response = handle:read("*a")
        handle:close()
        last_run = now
    end

    -- draw using cached_response
end

This runs the command on the first update (since last_run starts at 0) and then at real-time intervals regardless of how fast or slow Conky's update cycle is.

Running on Startup

If you need to run a command once when Conky starts (e.g. to populate initial data before the first timer fires), the preferred approach is to use the lua_startup_hook:

-- conky.conf
conky.config = {
    lua_load = "script.lua",
    lua_startup_hook = "startup",
    lua_draw_hook_post = "main",
}
-- script.lua
function conky_startup()
    local handle = io.popen("df -h")
    cached_output = handle:read("*a")
    handle:close()
end

function conky_main()
    if conky_window == nil then return end

    local updates = tonumber(conky_parse("${updates}"))
    if updates % 30 == 0 then
        local handle = io.popen("df -h")
        cached_output = handle:read("*a")
        handle:close()
    end

    -- draw using cached_output
end

In cases where the startup logic is tightly coupled with the draw hook (e.g. it's part of a larger function that also needs to run periodically), a flag variable can be used instead:

run_immediately = true

function conky_main()
    if conky_window == nil then return end

    local updates = tonumber(conky_parse("${updates}"))
    if updates % 30 == 0 or run_immediately then
        local handle = io.popen("df -h")
        cached_output = handle:read("*a")
        handle:close()
        run_immediately = nil
    end

    -- draw using cached_output
end

Parsing Output

Reading All Output

handle:read("*a") returns the entire command output as a single string. This is the simplest approach and works well for short outputs:

local handle = io.popen("hostname")
local name = handle:read("*a"):gsub("\n$", "")  -- trim trailing newline
handle:close()

Reading Line by Line

For multi-line output, iterating with handle:lines() processes one line at a time without loading everything into memory:

local handle = io.popen("df -h")
local partitions = {}
for line in handle:lines() do
    local mount = string.match(line, "%%(%s+/.*)")
    if mount then
        table.insert(partitions, mount)
    end
end
handle:close()

Extracting Data with Patterns

The real power comes from combining io.popen() with Lua's pattern matching to extract structured data from command output.

Example: Disk usage table

Parse df -h output into a table of mount points with their usage percentages:

local handle = io.popen("df -h")
local disks = {}
for line in handle:lines() do
    local used, mount = string.match(line, "(%d+)%%%s+(/.*)$")
    if used and mount then
        table.insert(disks, {mount = mount, percent = tonumber(used)})
    end
end
handle:close()

-- disks is now:
-- { {mount = "/", percent = 28}, {mount = "/home", percent = 52}, ... }

The pattern "(%d+)%%%s+(/.*)$" breaks down as:

  • (%d+) — capture one or more digits (the usage percentage)
  • %% — literal % character
  • %s+ — one or more spaces
  • (/.*)$ — capture everything from / to end of line (the mount point)

Example: Network interface status

local handle = io.popen("ip -br addr")
local interfaces = {}
for line in handle:lines() do
    local name, state, addr = string.match(line, "(%S+)%s+(%S+)%s+(.*)")
    if name and state == "UP" then
        interfaces[name] = addr:match("%S+") or "no address"
    end
end
handle:close()

Example: Fetching data from a URL

curl can be used to fetch data from web APIs or pages:

local updates = tonumber(conky_parse("${updates}"))
if updates % 300 == 0 or run_immediately then
    local handle = io.popen('curl -s "https://api.example.com/data"')
    cached_response = handle:read("*a")
    handle:close()
    run_immediately = nil
end

Tip

Use curl -s (silent mode) to suppress progress output. Pay attention to quote nesting — if the Lua string uses double quotes, use single quotes for the shell command arguments (or vice versa).

Building a Reusable Command Runner

For scripts that run multiple commands on different intervals, a helper function keeps things clean:

-- Cache table: stores results and timing
local cmd_cache = {}

function run_cached(cmd, interval)
    local updates = tonumber(conky_parse("${updates}"))
    local entry = cmd_cache[cmd]
    if not entry or updates % interval == 0 then
        local handle = io.popen(cmd)
        local output = handle:read("*a")
        handle:close()
        cmd_cache[cmd] = {output = output, updated = updates}
        return output
    end
    return entry.output
end

-- Usage:
local disk_info = run_cached("df -h", 30)
local who_info = run_cached("who", 60)

Clone this wiki locally