Minimum, Maximum and Average

dataaggregatestatisticsquery

Run min, max and aggregate queries on a device and store the results

luauaggregate-min-max-avg.luau
--[[
  Analysis Example
  Minimum, maximum, and average

  getData takes the same query the API takes. The query key selects the method:
  last_value, min, max, avg, sum, count, aggregate, and the rest.

  "function" is a Lua keyword, so the aggregate function goes in brackets:
  ["function"] = "avg".

  Instructions
  Add an environment variable device_id, and grant the Analysis "Get Data" and
  "Send Data" on that device through an Access Management policy.
]]

local function scalar(rows)
  local first = rows[1]
  return first and tonumber(first.value) or nil
end

Analysis.use(function(context, scope)
  local device = Devices.get(context.environment.device_id)
  local window = "1 day"

  local minimum = scalar(device:getData({ variables = "temperature", query = "min", start_date = window }))
  local maximum = scalar(device:getData({ variables = "temperature", query = "max", start_date = window }))

  if not minimum or not maximum then
    return print("no temperature in the last day")
  end

  print("min", minimum, "max", maximum)

  -- One bucket per hour over the same window.
  local hourly = device:getData({
    variables = "temperature",
    query = "aggregate",
    ["function"] = "avg",
    interval = "1 hour",
    timezone = "America/New_York",
    start_date = window,
  })

  print("buckets", #hourly)

  local batch = {
    { variable = "temperature_minimum", value = minimum, unit = "C" },
    { variable = "temperature_maximum", value = maximum, unit = "C" },
  }

  for _, bucket in hourly do
    table.insert(batch, {
      variable = "temperature_hourly",
      value = bucket.value,
      unit = "C",
      time = bucket.time,
    })
  end

  -- A batch lands whole or the call fails. There is no partial write.
  print(device:addData(batch))
end)

Structure an Analysis

structurepatternsscoperouting

Organize a script with local helpers and a table of handlers

luauanalysis-structure.luau
--[[
  Analysis Example
  Structure a larger script

  The root chunk runs first, with the same globals as the callback. Declare the
  helpers there and keep the callback short.

  Globals are frozen, so state lives in locals. Runs share nothing and are never
  retried, so keep each handler idempotent.

  Instructions
  Trigger this Analysis from an Action on the variables below. The handler table
  routes each item in scope to the function that owns it.
]]

local ALERT_LIMIT = 40

local function toNumber(item)
  return tonumber(item.value)
end

local function handleTemperature(context, device, item)
  local celsius = toNumber(item)
  if not celsius then
    return print("temperature is not numeric")
  end

  return {
    { variable = "temperature_f", value = celsius * 1.8 + 32, unit = "F", time = item.time },
    { variable = "temperature_alert", value = celsius > ALERT_LIMIT, time = item.time },
  }
end

local function handleBattery(context, device, item)
  local percent = toNumber(item)
  if not percent or percent > 20 then
    return nil
  end

  print("battery low on", device.name)
  return { { variable = "battery_alert", value = percent, unit = "%", time = item.time } }
end

local handlers = {
  temperature = handleTemperature,
  battery = handleBattery,
}

Analysis.use(function(context, scope)
  if type(scope) ~= "table" or #scope == 0 then
    return print("nothing to process")
  end

  local batch = {}
  local device = Devices.get(scope[1].device)

  for _, item in scope do
    local handler = handlers[item.variable]
    if handler then
      local produced = handler(context, device, item)
      for _, row in produced or {} do
        table.insert(batch, row)
      end
    end
  end

  if #batch == 0 then
    return print("no output for this trigger")
  end

  -- One call for the whole batch: one request and one rate limit token.
  print(device:addData(batch))
end)

Console Hello World

basicconsolehello-world

Write to the Analysis console with print

luauconsole.luau
--[[
  Analysis Example
  Hello World

  A Sandbox Analysis registers one callback with Analysis.use. The host calls it
  once, after the root chunk finishes.

  print writes to the Analysis console.
]]

Analysis.use(function(context, scope)
  print("Hello World")
  print("analysis", context.analysis_id)

  print("print also reaches the console")

  if scope then
    print("scope", Json.encode(scope))
  end
end)

Create Device

devicescreatetagsparameters

Create a device with tags and set its initial configuration parameters

luaucreate-device.luau
--[[
  Analysis Example
  Create a device

  Devices.create returns a handle for the new device. The generated device token
  never reaches the script.

  Access Management matches create by tag, never by ID, so the policy has to
  allow "Create" on a tag that the payload carries.

  The handle holds the values you submitted plus the ID the platform returned.
  Server side fields such as created_at arrive only after Devices.get.

  Instructions
  Add a policy targeting this Analysis that allows "Create", "Access", and
  "Edit" on devices with the tag kind = sensor.
]]

local SERIAL = "TAGO-0001"

Analysis.use(function(context, scope)
  local existing = Devices.list({
    amount = 1,
    fields = { "id", "name" },
    filter = { tags = { { key = "serial", value = SERIAL } } },
  })

  if existing[1] then
    return print("device already exists", existing[1].id)
  end

  local device = Devices.create({
    name = `Sensor {SERIAL}`,
    type = "immutable",
    description = "Created from a Sandbox Analysis",
    chunk_period = "month",
    chunk_retention = 1,
    tags = {
      { key = "kind", value = "sensor" },
      { key = "serial", value = SERIAL },
    },
  })

  print("created", device.id, device.name)

  -- Parameters are set after create, so a failure here is visible.
  print(device:setParams({
    { key = "reading_interval", value = "300" },
    { key = "serial", value = SERIAL, sent = true },
  }))
end)

Dates and Timezones

datetimezoneaggregatedata

Bucket readings into local days with the Date helpers

luaudate-timezone-buckets.luau
--[[
  Analysis Example
  Dates and timezones

  Every date on the wire is an ISO 8601 string. The Date helpers work on
  milliseconds: Date.parse reads a string, Date.format writes one.

  startOf, endOf, weekday, and isSame take a timezone, so a "day" is the local
  day of the site, not UTC.

  A getData reply has a 64 KiB budget, so read in pages with qty and skip
  instead of asking for everything at once.

  Instructions
  Add environment variables device_id and timezone, for example
  America/Chicago.
]]

local PAGE_SIZE = 200

local function readPage(device, from, to, skip)
  return device:getData({
    variables = "energy",
    qty = PAGE_SIZE,
    skip = skip,
    start_date = Date.format(from),
    end_date = Date.format(to),
    ordination = "ascending",
  })
end

Analysis.use(function(context, scope)
  local timezone = context.environment.timezone or "UTC"
  if not Date.isTimezone(timezone) then
    error(`unknown timezone "{timezone}"`)
  end

  local device = Devices.get(context.environment.device_id)

  local today = Date.startOf(Date.now(), "day", timezone)
  local weekAgo = Date.sub(today, { days = 7 })

  print("window", Date.format(weekAgo, timezone), Date.format(today, timezone))
  print("offset minutes", Date.offsetMinutes(today, timezone))

  -- Sum each local day, keyed by its own start of day.
  local totals = {}
  local skip = 0

  while true do
    local rows = readPage(device, weekAgo, today, skip)

    for _, row in rows do
      local value = tonumber(row.value)
      if value and row.time then
        local day = Date.startOf(Date.parse(row.time), "day", timezone)
        totals[day] = (totals[day] or 0) + value
      end
    end

    if #rows < PAGE_SIZE then
      break
    end
    skip += PAGE_SIZE
  end

  local batch = {}
  for day, total in totals do
    table.insert(batch, {
      variable = "energy_daily",
      value = total,
      unit = "kWh",
      time = Date.format(day),
      metadata = { weekday = Date.weekday(day, timezone) },
    })
  end

  if #batch == 0 then
    return print("no energy readings in the last 7 days")
  end

  print(device:addData(batch))
end)

Device Last Value

devicesdatalast-valueadd-data

Read the last value of a variable and write a derived variable back

luaudevice-last-value.luau
--[[
  Analysis Example
  Last value

  Devices.get runs immediately and returns a handle. A wrong ID, an inactive
  device, or a denied policy fails at the get call, not later.

  Handle methods use the colon: device:getData(query).

  Instructions
  1 - Add an environment variable device_id with the ID of your device.
  2 - Add an Access Management policy targeting this Analysis that allows
      "Access", "Get Data", and "Send Data" on that device.
]]

Analysis.use(function(context, scope)
  local device = Devices.get(context.environment.device_id)

  print("device", device.name, device.type)

  local rows = device:getData({ variables = "temperature", query = "last_value" })
  local last = rows[1]

  if not last then
    return print("no temperature yet")
  end

  print("last value", last.value, "at", last.time)

  local celsius = tonumber(last.value)
  if not celsius then
    return print("temperature is not numeric")
  end

  print(device:addData({
    variable = "temperature_f",
    value = celsius * 1.8 + 32,
    unit = "F",
    time = last.time,
  }))
end)

List Devices by Tag

deviceslisttagsfiltering

Page through devices filtered by tag and act on each handle

luaudevice-list-by-tag.luau
--[[
  Analysis Example
  List devices by tag

  Devices.list returns handles, so every row can be read and written without a
  second Devices.get. Rows carry only the fields you ask for, plus id and tags.

  amount defaults to 20 and the whole reply shares a 64 KiB budget. Page with
  amount and page, and request only the fields you need.

  Instructions
  Grant the Analysis "Access" and "Send Data" on the devices through an Access
  Management policy that targets the tag below.
]]

local PAGE_SIZE = 50

local function listPage(page)
  return Devices.list({
    page = page,
    amount = PAGE_SIZE,
    fields = { "id", "name", "last_input", "tags" },
    orderBy = "name,asc",
    filter = {
      active = true,
      tags = { { key = "kind", value = "sensor" } },
    },
  })
end

Analysis.use(function(context, scope)
  local now = Date.now()
  local page = 1
  local offline = 0

  while true do
    local devices = listPage(page)
    if #devices == 0 then
      break
    end

    for _, device in devices do
      local silentFor = if device.last_input
        then Date.diff(now, Date.parse(device.last_input), "hours")
        else math.huge

      if silentFor > 24 then
        offline += 1
        print("offline", device.name, device.id)
        device:addData({ variable = "offline", value = true })
      end
    end

    if #devices < PAGE_SIZE then
      break
    end
    page += 1
  end

  print("offline devices", offline)
end)

Device Configuration Parameters

devicesparametersconfigurationdownlink

Read configuration parameters with filters and upsert them in one call

luaudevice-parameters.luau
--[[
  Analysis Example
  Configuration parameters

  device:params filters on key (exact match) and sent_status. device:setParams
  upserts: an item with an id edits that parameter, an item without one creates
  it. At most 60 items per call, and 60 parameters per device.

  A common downlink pattern stores the pending command as an unsent parameter,
  then marks it sent once the network confirms it.

  Instructions
  Add an environment variable device_id, and a policy allowing "Access" and
  "Edit" on that device.
]]

local function byKey(params)
  local index = {}
  for _, param in params do
    index[param.key] = param
  end
  return index
end

Analysis.use(function(context, scope)
  local device = Devices.get(context.environment.device_id)

  local all = byKey(device:params())
  print("interval", all.reading_interval and all.reading_interval.value or "unset")

  local pending = device:params({ sent_status = false })
  for _, param in pending do
    print("pending", param.key, param.value)
  end

  -- Create or update by key, then flag the pending downlink as delivered.
  local changes = {
    { id = all.reading_interval and all.reading_interval.id, key = "reading_interval", value = "600" },
  }

  for _, param in pending do
    table.insert(changes, { id = param.id, key = param.key, value = param.value, sent = true })
  end

  print(device:setParams(changes))
end)

Edit Device

devicesedittags

Rename a device, replace its tags, and clear them with an empty list

luauedit-device.luau
--[[
  Analysis Example
  Edit a device

  device:edit returns a new handle with the accepted changes applied. It makes
  no second request, and the handle it was called on stays unchanged. Fields the
  platform computes, such as updated_at, are not refreshed.

  Tags replace the whole list. An empty list clears every tag.

  type and chunk_period cannot change. network and connector must be sent
  together, and cannot change once the device has a token.

  Instructions
  Add an environment variable device_id, and a policy allowing "Access" and
  "Edit" on that device.
]]

Analysis.use(function(context, scope)
  local device = Devices.get(context.environment.device_id)

  local renamed = device:edit({
    name = `{device.name} (managed)`,
    description = "Renamed from a Sandbox Analysis",
    active = true,
    tags = {
      { key = "kind", value = "sensor" },
      { key = "managed_by", value = context.analysis_id },
    },
  })

  print("before", device.name)
  print("after", renamed.name)

  for _, tag in renamed.tags do
    print("tag", tag.key, tag.value)
  end

  -- Uncomment to drop every tag from the device.
  -- renamed:edit({ tags = {} })
end)

JSON, Base64, Hex and UUID

jsonbase64hexuuidutilities

Use the host utility globals to encode, decode and identify data

luauencoding-utilities.luau
--[[
  Analysis Example
  Host utilities

  Json, Base64, Hex, and Uuid are always available. There is no require and no
  JSON.parse.

  Base64.decode and Hex.decode return a buffer, not a string. Use
  buffer.tostring for text and the buffer read functions for binary fields.
]]

Analysis.use(function(context, scope)
  local device = Devices.get(context.environment.device_id)

  -- Json round trip. metadata travels as a table, not as a string.
  local settings = Json.decode(context.environment.settings or "{}")
  print("settings", Json.encode(settings))

  -- Base64 for text.
  local encoded = Base64.encode("firmware=2.4.1")
  print("base64", encoded, buffer.tostring(Base64.decode(encoded)))

  -- Hex for binary frames. Read fields with the buffer library.
  local frame = Hex.decode("0a1f00c8")
  print("frame bytes", buffer.len(frame))
  print("battery", buffer.readu8(frame, 0))
  print("counter", bit32.lshift(buffer.readu8(frame, 2), 8) + buffer.readu8(frame, 3))

  -- A run has no shared state, so generate correlation IDs when you need them.
  local runId = Uuid.v4()
  print("run", runId)

  print(device:addData({
    variable = "heartbeat",
    value = 1,
    metadata = { run = runId, settings = settings },
  }))
end)

Environment Variables

basicenvironmentconfiguration

Read Analysis variables and trigger values from context.environment

luauenvironment-variables.luau
--[[
  Analysis Example
  Environment variables

  context.environment is a read-only string dictionary. It holds the variables
  set on the Analysis screen plus the reserved values the trigger adds, such as
  device, action, and dashboard identifiers.

  Values are always strings. Convert them before use.

  The console has an 8 KiB budget for the whole run. Log what you need to
  debug, not every value you touch.

  Instructions
  Add a variable named device_id on the Analysis "Environment Variables" tab and
  set it to the ID of a device the Analysis can access.
]]

local function required(context, key)
  local value = context.environment[key]
  if not value or value == "" then
    error(`missing environment variable "{key}"`)
  end
  return value
end

Analysis.use(function(context, scope)
  local device_id = required(context, "device_id")
  local threshold = tonumber(context.environment.threshold) or 30

  print("device_id", device_id)
  print("threshold", threshold)

  for key, value in context.environment do
    print("env", key, value)
  end
end)

Handle Binding Errors

error-handlingpcalldevicesresilience

Wrap a binding call in pcall and read the error message

luauerror-handling.luau
--[[
  Analysis Example
  Handle binding errors

  A failed binding raises. Without pcall the run stops at that line and the
  message reaches the Analysis console.

  pcall returns ok plus the value or the error. The error is the plain message,
  so tostring gives you the text to log or to store.

  Common failures:
    Authorization Denied    the policy does not cover this device action
    Rate Limit Exceeded     the profile ran out of RPM for that resource
    a device API message    the platform rejected the operation
    a wrong argument hint   the query or payload shape is invalid

  Side effects that already completed are not rolled back when a later call
  fails. Write idempotent scripts.
]]

local function attempt(context, label, fn)
  local ok, result = pcall(fn)
  if not ok then
    print(label, "failed:", tostring(result))
    return nil
  end
  return result
end

Analysis.use(function(context, scope)
  local device = attempt(context, "get", function()
    return Devices.get(context.environment.device_id)
  end)

  if not device then
    return
  end

  local rows = attempt(context, "getData", function()
    return device:getData({ variables = "temperature", query = "last_value" })
  end)

  local last = rows and rows[1]
  if not last then
    return print("no reading to forward")
  end

  local written = attempt(context, "addData", function()
    return device:addData({ variable = "temperature_copy", value = last.value, time = last.time })
  end)

  print("result", written or "not written")
end)

Parse Trigger Payload

scopepayloaddecodeadd-data

Decode a raw hex payload from the trigger scope and post the parsed variables

luauparse-scope-payload.luau
--[[
  Analysis Example
  Parse the trigger payload

  When an Action triggers the Analysis, scope is the array of data items that
  fired it. Each item carries at least variable, value, and device.

  This replaces a payload parser for cases where the decoding needs more than
  the parser can do, such as reading other variables or writing to a second
  device.

  Instructions
  Trigger this Analysis from an Action on the variable "payload", holding a
  four character hex string: two bytes of temperature in tenths of a degree.
]]

local function findVariable(items, name)
  for _, item in items do
    if item.variable == name then
      return item
    end
  end
  return nil
end

local function decodeTemperature(hex)
  local bytes = Hex.decode(hex)
  if buffer.len(bytes) < 2 then
    error("payload needs at least 2 bytes")
  end

  local high = buffer.readu8(bytes, 0)
  local low = buffer.readu8(bytes, 1)
  return (bit32.lshift(high, 8) + low) / 10
end

Analysis.use(function(context, scope)
  if type(scope) ~= "table" then
    return print("no trigger payload")
  end

  local payload = findVariable(scope, "payload")
  if not payload then
    return print("variable payload not found in scope")
  end

  local device = Devices.get(payload.device)
  local temperature = decodeTemperature(payload.value)

  print("decoded", temperature)

  local result = device:addData({
    { variable = "temperature", value = temperature, unit = "C", time = payload.time },
    { variable = "temperature_status", value = if temperature > 30 then "high" else "normal" },
  })

  print(result)
end)