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
- Route between connections - Forward messages across multiple connections
- Extract telemetry - Add chart-ready values for real-time visualization
- Log diagnostics - Output debug information during development
Architecture
Multi-Connection Support: CycBox runs a single shared Lua task that processes messages from all connections. This enables powerful cross-connection orchestration while maintaining resource efficiency.
┌────────────────────────────────────────────────────────────────┐
│ Connection 0 Connection 1 │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │
│ │Transport│ │Processor │ │Transport│ │Processor │ │
│ └────┬────┘ └────┬─────┘ └────┬────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │
│ └─────────────┬─────────────┘ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Shared Lua Task │ ← Single Lua instance │
│ │ - on_receive() │ │
│ │ - on_send() │ │
│ │ - on_send_confirm() │ │
│ │ - on_timer() │ │
│ └───────────┬─────────────┘ │
│ ▼ │
│ UI │
└────────────────────────────────────────────────────────────────┘
RX Path:
Transport → [Codec Decode] → [Transformer] → [Format] → Lua [on_receive] → UI
TX Path :
UI → Lua [on_send] → [Transformer] → [Codec Encode] → Transport
↓
[Format] → Lua [on_send_confirm] → UI
Timer:
Every 100ms → [on_timer - global]
Key Features:
- Connection ID Tracking: Every message carries a
connection_idfield identifying its source/destination connection - Message Routing: Lua can inspect and modify
connection_idto route messages between connections, or usesend_after()to schedule messages to specific connections - Connection Query APIs: Query transport/codec types for each connection
- Global Timer:
on_timer()runs once globally every 100ms (not per-connection)
Scripts can implement any combination of hooks to intercept and modify messages at different stages. The global
message object is available in hooks for reading and modifying message data.
Getting Started
Quick Start Workflow
- Open the Script Tab in CycBox UI
- Choose your hook(s) based on your needs:
- Need to parse incoming data? → Use
on_receive() - Need to modify outgoing messages? → Use
on_send() - Need periodic tasks/polling? → Use
on_timer() - Need initialization? → Use
on_start()
- Need to parse incoming data? → Use
- Write your script with the chosen hooks
- Test incrementally - start with
log()statements to verify execution - Add data processing - use binary reading functions and add values to messages
- Verify in UI - check logs, message display, and charts
Tip: Start simple! A script with just on_receive() that logs the payload length is a great first test.
Basic Script Structure
Lua scripts can implement any combination of these five optional hooks:
-- Called once when Lua task starts (before any connections)
-- Use for initialization, setup, or startup logging
-- Can query connection information
function on_start()
local count = get_connection_count()
log("info", "Engine started with " .. count .. " connection(s)")
-- Query each connection's transport and codec
for i = 0, count - 1 do
local transport = get_transport(i)
local codec = get_codec(i)
log("info", "Connection " .. i .. ": " .. transport .. " with " .. codec)
end
end
-- Called for each received message from ANY connection (RX path)
-- Access global 'message' object to read/modify message fields
-- message.connection_id identifies which connection the message came from
-- MUST return true if message was modified, false otherwise
function on_receive()
local conn_id = message.connection_id
local payload = message.payload
log("info", "Received from connection " .. conn_id .. ": " .. #payload .. " bytes")
-- Your processing logic here
return false -- Return true if message was modified
end
-- Called for each outgoing message (TX path, before processor)
-- Access global 'message' object to read/modify message fields
-- message.connection_id controls which connection will receive the message
-- Can modify connection_id to route to different connection
-- MUST return true if message was modified, false otherwise
function on_send()
local conn_id = message.connection_id
log("info", "Sending to connection " .. conn_id)
-- Your processing logic here
return false -- Return true if message was modified
end
-- Called for each TX confirmation (after successful send)
-- Useful for logging confirmations or triggering follow-up actions
-- MUST return true if message was modified, false otherwise
function on_send_confirm()
local conn_id = message.connection_id
log("info", "Message sent successfully on connection " .. conn_id)
return false
end
-- Called periodically (every 100ms) - GLOBAL timer
-- NOT called per-connection - runs once globally
-- Use for polling, timers, or periodic tasks across all connections
function on_timer(elapsed_ms)
-- elapsed_ms: milliseconds since Lua task started
-- Example: Poll different connections based on elapsed time
end
Important: For message hooks (on_receive, on_send, on_send_confirm), the return value controls whether the
modified message is copied back:
return true- Message modifications are applied (payload, values, connection_id, etc.)return false- Message passes through unchanged (performance optimization)
Connection ID: Every message has a connection_id field (0-based index). Lua can:
- Read
message.connection_idto determine source connection inon_receive() - Modify
message.connection_idto route TX messages to different connections
Simple Example: Logging and Payload Inspection
Start with this minimal script to verify Lua integration:
-- Simple logging example with multi-connection support
function on_start()
local count = get_connection_count()
log("info", "Lua script initialized with " .. count .. " connection(s)!")
-- Log each connection's configuration
for i = 0, count - 1 do
local transport = get_transport(i)
local codec = get_codec(i)
log("info", "Connection " .. i .. ": " .. transport .. "/" .. codec)
end
end
function on_receive()
local conn_id = message.connection_id
local payload = message.payload
local transport = get_transport(conn_id)
log("info", "Received " .. #payload .. " bytes from connection " .. conn_id .. " (" .. transport .. ")")
-- Display payload as hex string
local hex = ""
for i = 1, math.min(#payload, 16) do -- First 16 bytes only
hex = hex .. string.format("%02X ", string.byte(payload, i))
end
message:add_content("Hex: " .. hex)
return true -- We added content
end
function on_send()
local conn_id = message.connection_id
log("info", "Sending message with " .. #message.payload .. " bytes to connection " .. conn_id)
return false -- Not modifying
end
function on_send_confirm()
local conn_id = message.connection_id
log("info", "Message sent successfully on connection " .. conn_id)
return false
end
This script demonstrates:
- Multi-connection awareness - Queries and logs all connection configurations
- Connection ID tracking - Uses
message.connection_idto identify source/destination - Connection query APIs - Uses
get_transport(),get_codec(),get_connection_count() - Payload access with
message.payload - Logging received/sent data with connection context
- Content display with
message:add_content() - TX confirmation tracking with
on_send_confirm() - Proper return values (true when modified, false when not)
Multi-Connection Features
Connection Query APIs
The Lua environment provides functions to query information about all active connections:
-- Get total number of connections
local count = get_connection_count() -- Returns: integer (e.g., 2)
-- Get transport type for a specific connection
local transport = get_transport(0) -- Returns: string (e.g., "serial", "tcp_client", "websocket_client")
-- Get codec type for a specific connection
local codec = get_codec(1) -- Returns: string (e.g., "modbus_rtu", "line", "frame")
Note: Connection IDs are 0-based indices (0, 1, 2, ..., count-1).
Legacy APIs (for backward compatibility with single-connection scripts):
-- These return first connection's (connection_id = 0) transport/codec
local transport = app_transport() -- Same as get_transport(0)
local codec = app_codec() -- Same as get_codec(0)
Message Routing Between Connections
The connection_id field enables powerful cross-connection routing:
-- Example: Forward serial data to TCP connection
function on_receive()
local conn_id = message.connection_id
local transport = get_transport(conn_id)
-- If received from serial (connection 0), forward to TCP (connection 1)
if transport == "serial" and get_connection_count() > 1 then
log("info", "Forwarding serial data to TCP connection")
-- Send a copy to connection 1 (will be handled in next on_send)
send_after(message.payload, 0, 1) -- delay=0ms, connection_id=1
end
return false -- Original message continues to UI
end
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_receive()
local conn_id = message.connection_id
local payload = message.payload
-- Validate payload length (26 bytes after prefix/length/checksum are removed)
if #payload ~= 26 then
log("warn", "Connection " .. conn_id .. ": 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)
message:add_int_value("PM1.0 (μg/m³)", pm1_0_cf1)
message:add_int_value("PM1.0 (μg/m³)", pm1_0_atm,)
message:add_int_value("PM2.5 (μg/m³)", pm2_5_cf1)
message:add_int_value("PM2.5 (μg/m³)", pm2_5_atm)
message:add_int_value("PM10 (μg/m³)", pm10_cf1)
message:add_int_value("PM10 (μg/m³)", pm10_atm)
-- Add particle counts to chart (all in same group for comparison)
message:add_int_value("Particles >0.3μm", particles_0_3um)
message:add_int_value("Particles >0.5μm", particles_0_5um)
message:add_int_value("Particles >1.0μm", particles_1_0um)
message:add_int_value("Particles >2.5μm", particles_2_5um)
message:add_int_value("Particles >5.0μm", particles_5_0um)
message:add_int_value("Particles >10μm", particles_10um)
-- 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
Lua API Reference
Global Hooks
All hooks are optional. Implement only the hooks you need.
on_start()
Called once when the Lua task starts (before processing any messages).
Use cases: Initialization, logging startup info, querying connection configuration
Example:
function on_start()
log("info", "Script started with " .. get_connection_count() .. " connections")
end
on_receive() → boolean
Called for each received message from any connection (RX path).
Global variables:
message- The received message object (withconnection_idset)
Return value:
true- Message was modified, changes will be appliedfalse- Message unchanged, optimization to avoid copying
Use cases: Parse protocols, extract telemetry, log received data, route messages
Example:
function on_receive()
local conn_id = message.connection_id
local payload = message.payload
-- Parse and add values
message:add_int_value("Temperature", read_i16_be(payload, 1))
return true -- Modified message
end
on_send() → boolean
Called for each outgoing message from UI (TX path, before processor).
Global variables:
message- The outgoing message object (withconnection_idset)
Return value:
true- Message was modified, changes will be appliedfalse- Message unchanged
Use cases: Modify payloads, change routing, add checksums, log outgoing data
Example:
function on_send()
-- Route based on connection type
if get_transport(message.connection_id) == "serial" then
-- Modify for serial connection
return false
end
return false
end
on_send_confirm() → boolean
Called for each TX confirmation after successful send.
Global variables:
message- The confirmed message object (withconnection_idset)
Return value:
true- Message was modified (rarely needed)false- Message unchanged
Use cases: Log confirmations, trigger follow-up actions, track sent messages
Example:
function on_send_confirm()
log("info", "Sent " .. #message.payload .. " bytes on connection " .. message.connection_id)
return false
end
on_timer(elapsed_ms)
Called periodically every 100ms (global timer, not per-connection).
Parameters:
elapsed_ms(number) - Milliseconds since Lua task started
Use cases: Periodic polling, scheduled tasks, timeout detection
Example:
local last_poll_time = 0
function on_timer(elapsed_ms)
-- Poll every second
if elapsed_ms - last_poll_time >= 1000 then
send_after("POLL\n", 0, 0) -- Send to connection 0
last_poll_time = elapsed_ms
end
end
Connection Query Functions
get_connection_count() → number
Returns the total number of active connections.
Returns: Integer count (e.g., 2)
Example:
local count = get_connection_count()
for i = 0, count - 1 do
log("info", "Connection " .. i .. " exists")
end
get_transport(connection_id) → string
Returns the transport type for the specified connection.
Parameters:
connection_id(number) - Connection index (0-based)
Returns: Transport type string (e.g., "serial", "tcp_client", "websocket_client", "mqtt", "udp")
Example:
local transport = get_transport(0)
if transport == "serial" then
log("info", "Connection 0 is a serial port")
end
get_codec(connection_id) → string
Returns the codec type for the specified connection.
Parameters:
connection_id(number) - Connection index (0-based)
Returns: Codec type string (e.g., "modbus_rtu", "modbus_tcp", "line", "frame", "passthrough")
Example:
local codec = get_codec(1)
if codec == "modbus_rtu" then
log("info", "Connection 1 uses Modbus RTU")
end
Utility Functions
log(level, message)
Logs a message to the application log.
Parameters:
level(string, optional) - Log level:"debug","info","warn","error"(default:"info")message(string, optional) - Message to log (default:"<nil>")
Example:
log("info", "Processing started")
log("warn", "Invalid data received")
log("error", "Critical failure")
log("debug", "Variable x = " .. x)
send_after(payload, delay_ms, connection_id) → boolean
Schedules a message to be sent after a delay to a specific connection.
Parameters:
payload(string) - Message payload (binary data as Lua string) - required, cannot be nildelay_ms(number) - Delay in milliseconds before sendingconnection_id(number, optional) - Target connection ID (default: 0)
Returns:
true- Message scheduled successfullyfalse- Failed to schedule (channel closed or error)- Error if payload is nil
Example:
-- Send to default connection (0) after 1 second
send_after("Hello\n", 1000)
-- Send to connection 1 immediately
send_after("PING\n", 0, 1)
-- Send binary data after 500ms to connection 0
send_after("\x01\x03\x00\x00\x00\x0A", 500, 0)
Message Object API
The global message object in hooks provides access to message data and metadata.
Fields
message.connection_id(number) - Source/destination connection ID (0-based, can be modified in on_send)message.payload(string) - Raw message payload as binary stringmessage.contents(string) - Formatted display text (read-only)message.values(table) - Extracted data values (read-only)message.metadata(table) - Transport-specific metadata (read-only)
Methods
message:add_content(text)- Add display textmessage:add_int_value(id, value, group)- Add integer telemetry valuemessage:add_float_value(id, value, group)- Add floating-point telemetry valuemessage:add_string_value(id, value, group)- Add string value
See Message API documentation for complete details.
Binary Data Reading Functions
Functions for parsing binary protocol payloads. All use 1-based indexing (Lua convention).
Available functions:
read_u8(payload, offset)- Read unsigned 8-bit integerread_i8(payload, offset)- Read signed 8-bit integerread_u16_be(payload, offset)- Read unsigned 16-bit big-endianread_u16_le(payload, offset)- Read unsigned 16-bit little-endianread_i16_be(payload, offset)- Read signed 16-bit big-endianread_i16_le(payload, offset)- Read signed 16-bit little-endianread_u32_be(payload, offset)- Read unsigned 32-bit big-endianread_u32_le(payload, offset)- Read unsigned 32-bit little-endianread_i32_be(payload, offset)- Read signed 32-bit big-endianread_i32_le(payload, offset)- Read signed 32-bit little-endianread_float_be(payload, offset)- Read 32-bit float big-endianread_float_le(payload, offset)- Read 32-bit float little-endianread_double_be(payload, offset)- Read 64-bit double big-endianread_double_le(payload, offset)- Read 64-bit double little-endian
Example:
local payload = message.payload
local temp = read_i16_be(payload, 1) -- Read bytes 1-2 as signed 16-bit BE
local humidity = read_u16_le(payload, 3) -- Read bytes 3-4 as unsigned 16-bit LE
Protocol Helper Functions
Additional protocol-specific helpers are documented in separate pages:
- Modbus helpers -
modbus_read_coils(),modbus_write_single_register(), etc. - MQTT helpers -
mqtt_publish(topic, payload, qos, retain, delay, connection_id) - UDP helpers -
udp_send(destination_address, destination_port, payload, delay, connection_id)
All protocol helpers accept an optional connection_id parameter to target specific connections.