Lua Script#

The Lua Script module provides a powerful, sandboxed scripting environment for custom message processing in CycBox. It enables real-time transformation, parsing, and analysis of incoming messages without requiring plugin compilation.

Overview#

The Lua Script system intercepts messages in the processing pipeline, allowing you to:

  • Parse custom protocols - Decode proprietary or undocumented data formats
  • Transform message data - Modify payloads, extract values, or add metadata
  • Schedule delayed messages - Send commands with precise timing control
  • Extract telemetry - Add chart-ready values for real-time visualization
  • Log diagnostics - Output debug information during development

Architecture#

Lua scripts execute within the CycBox message processing pipeline:

Transport → Codec → Formatter → Panel → Lua Script → UI
                                             ↓
                                        on_message()

The on_message() hook receives each incoming message as a global message object, which can be read and modified in-place. Scripts run with a configurable timeout (default: 1000ms) to prevent blocking.

Getting Started#

Basic Script Structure#

Every Lua script must implement the on_message() function:

-- Called for each received message
-- Access global 'message' object to read/modify message fields
-- MUST return true if message was modified, false otherwise
function on_message()
    -- Your processing logic here
    return false  -- Return true if message was modified
end

Important: The return value controls whether the modified message is copied back:

  • true - Message modifications are applied (payload, values, etc.)
  • false - Message passes through unchanged (performance optimization)

Message API#

The global message object provides the following methods:

Payload Access#

-- Read/write raw payload (bytes)
local payload = message:get_payload()
message:set_payload(bytes)

-- Read/write framed data (includes protocol overhead)
local frame = message:get_frame()
message:set_frame(bytes)

Value Extraction (for Charts)#

Read existing values decoded by earlier pipeline stages:

-- Returns value by id if exists, nil otherwise
-- Supported types: boolean, int8-64, uint8-64, float32/64, string
local temperature = message:get_value("temperature")

Write new values for visualization in the Dashboard panel:

-- Add integer value
message:add_int_value(id, value, [group])

-- Add floating-point value
message:add_float_value(id, value, [group])

-- Add string value (for labels/status)
message:add_string_value(id, value, [group])

-- Add boolean value
message:add_bool_value(id, value, [group])

Parameters:

  • id (string) - Value identifier, shown as chart title
  • value - Numeric or string data
  • group (optional string) - Group name for multi-series charts (values with same id but different groups appear on same chart)

Metadata#

-- Get message timestamp (microseconds since epoch)
local timestamp = message:get_timestamp()

Utility Functions#

Logging#

log(level, message)

Levels: "info", "warn", "error"

Example:

log("info", "Processing message with " .. #payload .. " bytes")
log("warn", "Unexpected payload length")

Delayed Message Sending#

success = send_after(payload, delay_ms)

Schedule a message to be sent after a specified delay.

Parameters:

  • payload (string) - Message data to send
  • delay_ms (integer) - Delay in milliseconds

Returns: true on success, false on failure

Example:

-- Send command after 500ms
local ok = send_after("AT+INFO\r\n", 500)
if not ok then
    log("error", "Failed to schedule message")
end

Binary Reading Helpers#

CycBox provides helper functions for reading binary data from byte strings. All offsets are 1-indexed (Lua convention).

8-bit Integers#

value = read_u8(bytes, offset)   -- unsigned (0-255)
value = read_i8(bytes, offset)   -- signed (-128 to 127)

16-bit Integers#

value = read_u16_be(bytes, offset)  -- unsigned big-endian
value = read_u16_le(bytes, offset)  -- unsigned little-endian
value = read_i16_be(bytes, offset)  -- signed big-endian
value = read_i16_le(bytes, offset)  -- signed little-endian

32-bit Integers#

value = read_u32_be(bytes, offset)  -- unsigned big-endian
value = read_u32_le(bytes, offset)  -- unsigned little-endian
value = read_i32_be(bytes, offset)  -- signed big-endian
value = read_i32_le(bytes, offset)  -- signed little-endian

Floating-Point#

value = read_float_be(bytes, offset)   -- 32-bit big-endian
value = read_float_le(bytes, offset)   -- 32-bit little-endian
value = read_double_be(bytes, offset)  -- 64-bit big-endian
value = read_double_le(bytes, offset)  -- 64-bit little-endian

Error Handling: Functions throw runtime errors if offset is out of range.

Example: PMS9103M Air Quality Sensor#

The PMS9103M is a particulate matter sensor that outputs data in a custom binary protocol:

Protocol Specification#

Field Offset Type Description
Prefix 0-1 Bytes Fixed header 0x42 0x4D (“BM”)
Length 2-3 U16 BE Payload length (always 28 = 26 bytes data + 2 bytes checksum)
PM1.0 CF=1 4-5 U16 BE PM1.0 concentration (μg/m³, CF=1)
PM2.5 CF=1 6-7 U16 BE PM2.5 concentration (μg/m³, CF=1)
PM10 CF=1 8-9 U16 BE PM10 concentration (μg/m³, CF=1)
PM1.0 ATM 10-11 U16 BE PM1.0 concentration (μg/m³, atmospheric)
PM2.5 ATM 12-13 U16 BE PM2.5 concentration (μg/m³, atmospheric)
PM10 ATM 14-15 U16 BE PM10 concentration (μg/m³, atmospheric)
Particles >0.3μm 16-17 U16 BE Count per 0.1L air
Particles >0.5μm 18-19 U16 BE Count per 0.1L air
Particles >1.0μm 20-21 U16 BE Count per 0.1L air
Particles >2.5μm 22-23 U16 BE Count per 0.1L air
Particles >5.0μm 24-25 U16 BE Count per 0.1L air
Particles >10μm 26-27 U16 BE Count per 0.1L air
Reserved 28-29 U16 BE Version/error code
Checksum 30-31 U16 BE Sum16 checksum

Complete Implementation#

-- PMS9103M Air Quality Sensor Parser
-- Parses particulate matter concentrations and particle counts

function on_message()
    local payload = message:get_payload()

    -- Validate payload length (26 bytes after prefix/length/checksum are removed)
    if #payload ~= 26 then
        log("warn", "Invalid PMS9103M payload length: " .. #payload .. " (expected 26)")
        return false
    end

    -- Parse PM concentrations (CF=1, standard particles) in μg/m³
    local pm1_0_cf1 = read_u16_be(payload, 1)   -- Offset 1 (bytes 4-5 in full frame)
    local pm2_5_cf1 = read_u16_be(payload, 3)   -- Offset 3 (bytes 6-7)
    local pm10_cf1  = read_u16_be(payload, 5)   -- Offset 5 (bytes 8-9)

    -- Parse PM concentrations (atmospheric environment) in μg/m³
    local pm1_0_atm = read_u16_be(payload, 7)   -- Offset 7 (bytes 10-11)
    local pm2_5_atm = read_u16_be(payload, 9)   -- Offset 9 (bytes 12-13)
    local pm10_atm  = read_u16_be(payload, 11)  -- Offset 11 (bytes 14-15)

    -- Parse particle counts (number of particles per 0.1L air)
    local particles_0_3um = read_u16_be(payload, 13)  -- Offset 13 (bytes 16-17)
    local particles_0_5um = read_u16_be(payload, 15)  -- Offset 15 (bytes 18-19)
    local particles_1_0um = read_u16_be(payload, 17)  -- Offset 17 (bytes 20-21)
    local particles_2_5um = read_u16_be(payload, 19)  -- Offset 19 (bytes 22-23)
    local particles_5_0um = read_u16_be(payload, 21)  -- Offset 21 (bytes 24-25)
    local particles_10um  = read_u16_be(payload, 23)  -- Offset 23 (bytes 26-27)

    -- Add PM concentrations to charts (CF=1 vs Atmospheric comparison)
    -- Values with same id but different groups appear on the same chart
    message:add_int_value("PM1.0 (μg/m³)", pm1_0_cf1, "CF=1")
    message:add_int_value("PM1.0 (μg/m³)", pm1_0_atm, "ATM")

    message:add_int_value("PM2.5 (μg/m³)", pm2_5_cf1, "CF=1")
    message:add_int_value("PM2.5 (μg/m³)", pm2_5_atm, "ATM")

    message:add_int_value("PM10 (μg/m³)", pm10_cf1, "CF=1")
    message:add_int_value("PM10 (μg/m³)", pm10_atm, "ATM")

    -- Add particle counts to chart (all in same group for comparison)
    message:add_int_value("Particles >0.3μm", particles_0_3um, "Particle Count (per 0.1L)")
    message:add_int_value("Particles >0.5μm", particles_0_5um, "Particle Count (per 0.1L)")
    message:add_int_value("Particles >1.0μm", particles_1_0um, "Particle Count (per 0.1L)")
    message:add_int_value("Particles >2.5μm", particles_2_5um, "Particle Count (per 0.1L)")
    message:add_int_value("Particles >5.0μm", particles_5_0um, "Particle Count (per 0.1L)")
    message:add_int_value("Particles >10μm", particles_10um, "Particle Count (per 0.1L)")

    -- Log parsed values for debugging
    log("info", string.format("PM2.5: CF=%d ATM=%d μg/m³", pm2_5_cf1, pm2_5_atm))

    -- Return true since we added values to the message
    return true
end

Expected Output#

When connected to a PMS9103M sensor, the Dashboard panel will display:

  1. Three PM concentration charts (PM1.0, PM2.5, PM10):

    • Two series per chart: “CF=1” (blue) and “ATM” (red)
    • Y-axis: concentration in μg/m³
    • Real-time line charts with historical data
  2. Particle count chart:

    • Six series showing particle counts for different size thresholds
    • Y-axis: particles per 0.1L air
    • Useful for analyzing air quality distribution