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
endImportant: 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 titlevalue- Numeric or string datagroup(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 senddelay_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")
endBinary 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-endian32-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-endianFloating-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-endianError 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
endExpected Output#
When connected to a PMS9103M sensor, the Dashboard panel will display:
-
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
-
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