SmartThings Edge Driver Lua Library Architecture
1. Driver Initialization and Run Loop
A driver is created by calling Driver("name", template) (or a protocol-specific variant like ZigbeeDriver("name", template)). The template is a Lua table containing handler tables and configuration.
The base Driver.init (in lua_libs/st/driver.lua) does the following:
- Sets
out_driver.NAMEfrom the name argument - Initializes handler tables:
capability_handlers,lifecycle_handlers,message_handlers - Opens communication channels via cosock sockets:
capability_channel,environment_channel,lifecycle_channel,driver_lifecycle_channel, and optionallydiscovery_channel - Initializes a datastore and device cache tables
- Calls
Driver.standardize_sub_drivers()to normalize thesub_driverslist - Builds the
lifecycle_dispatcherandcapability_dispatcherfrom handlers + sub_drivers - Registers channel handlers so inbound messages get routed to the correct handler function
The driver:run() call starts the cosock event loop, which runs forever processing messages from all registered channels.
2. Message Dispatchers
The dispatcher system (lua_libs/st/dispatcher.lua) is a hierarchical message routing tree. The base class MessageDispatcher provides:
default_handlers- handlers at this level of the hierarchy.child_dispatchers- sub-dispatchers (from sub_drivers) that may override defaultscan_handle(driver, device, ...)- returns true if this dispatcher or a child can handle the messagedispatch(driver, device, ...)- finds and executes the matching handler
Dispatch logic
- The dispatcher calls
can_handleon each child dispatcher - If any children can handle: only the children handle it (parent defaults are NOT called)
- If multiple children match: ALL matching children receive the message
- If NO children match: parent defaults are used
- This is recursive -- sub-drivers can have sub-drivers
Dispatcher types
| Dispatcher | Class | Handles |
|---|---|---|
capability_dispatcher |
CapabilityCommandDispatcher |
Capability commands from the platform (on, off, setLevel, etc.) |
lifecycle_dispatcher |
DeviceLifecycleDispatcher |
Device lifecycle events (added, init, removed, etc.) |
zigbee_message_dispatcher |
ZigbeeMessageDispatcher |
Incoming Zigbee messages (attribute reports, cluster commands, ZDO) |
zwave_dispatcher |
ZwaveDispatcher |
Incoming Z-Wave commands |
matter_dispatcher |
MatterMessageDispatcher |
Incoming Matter interaction responses |
secret_data_dispatcher |
SecretDataDispatcher |
Security/secret data events |
Each protocol-specific driver (ZigbeeDriver, ZwaveDriver, MatterDriver) adds its own dispatcher on top of the base Driver's capability and lifecycle dispatchers.
Zigbee handler structure:
zigbee_handlers = {
attr = { -- attribute reports / read responses
[ClusterID] = {
[AttributeID] = handler_function,
}
},
global = { -- global ZCL commands
[ClusterID] = {
[CommandID] = handler_function,
}
},
cluster = { -- cluster-specific commands
[ClusterID] = {
[CommandID] = handler_function,
}
},
zdo = { -- ZDO commands
[ClusterID] = handler_function,
}
}
Z-Wave handler structure:
zwave_handlers = {
[cc.SWITCH_BINARY] = { -- command class
[SwitchBinary.REPORT] = handler_function, -- command ID
},
}
Matter handler structure:
matter_handlers = {
attr = {
[ClusterID] = {
[AttributeID] = handler_function,
}
},
cmd_response = { ... },
event = { ... },
fallback = handler_function,
}
Capability handler structure:
capability_handlers = {
[capabilities.switch.ID] = {
[capabilities.switch.commands.on.NAME] = handle_on,
[capabilities.switch.commands.off.NAME] = handle_off,
},
[capabilities.switchLevel.ID] = {
[capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level,
},
}
3. Sub-Drivers Pattern
Sub-drivers allow device-specific behavior overrides gated by a can_handle function. A sub-driver is a table with:
NAME(string)can_handle(opts, driver, device, ...) -> boolean- Protocol handlers (zigbee_handlers, zwave_handlers, matter_handlers)
capability_handlers,lifecycle_handlers- Optional nested
sub_drivers
In practice, sub-drivers are often organized as separate files under src/sub_drivers/ for clarity, and required in the main driver template.
Dispatch Logic
- The dispatcher calls
can_handleon each child dispatcher - If any children can handle: only the children handle it (parent defaults are NOT called)
- If multiple children match: ALL matching children receive the message
- If NO children match: parent defaults are used
- This is recursive -- sub-drivers can have sub-drivers
Lazy Loading
Sub-drivers support lazy loading for memory optimization:
Driver.lazy_load_sub_driver(sub_driver): Strips handlers, keeps onlycan_handleandNAMEDriver.lazy_load_sub_driver_v2(require_path): Even more efficient; only requirescan_handleandsub_driversmodules separately- A sub-driver with no handlers defined is automatically treated as lazy-loadable
New sub-drivers must be:
- Listed in the parent's
sub_drivers.lua(or the equivalent sub_drivers table) - Have a
can_handle.luathat correctly identifies the target devices - Have an
init.luathat returns the sub-driver table
If any of these are missing, the sub-driver will not be loaded.
4. Lifecycle Events
Device lifecycle events are dispatched through the DeviceLifecycleDispatcher. The key events:
init-- Called for every device on driver startup (existing devices) and afteraddedfor new devices. Used for setting up component/endpoint mappings and device fields.added-- Called only when a device is first paired. NOT called for existing devices when a driver is updated. Afteradded, a syntheticinitis automatically dispatched.doConfigure-- Called when the device needs configuration (typically after pairing).infoChanged-- Called when device metadata changes (e.g., preferences updated). Receivesargs.old_st_storefor comparison.removed-- Called when device is removed.driverSwitched-- Called when device switches to this driver.
Register lifecycle handlers in the template:
lifecycle_handlers = {
init = device_init,
added = device_added,
removed = device_removed,
doConfigure = device_do_configure,
infoChanged = info_changed_handler,
}
Handler signature: function(driver, device, event, args)
Default behaviors provided by the framework:
driverSwitched: Base Driver marks device asNONFUNCTIONAL. ZigbeeDriver overrides this to check capability matching and marks asPROVISIONEDif all capabilities match.doConfigure: ZigbeeDriver defaults todevice_management.configurewhich sends attribute reporting configuration.added: After a successfuladdedcallback, the framework automatically queues a syntheticinitevent.doConfigure: After success, the framework transitions the device toPROVISIONEDstate.- Unhandled lifecycle events log a trace message and are otherwise ignored (fallback handler).
Critical timing knowledge for lifecycle events
initon driver startup. After hub restart the radio may not be ready and sending Zigbee/Z-Wave commands ininitcan fail.addedis NOT called for existing devices on driver update. Only called on first pair. Code that must run for existing devices should go ininit(for non-radio operations) or usedriverSwitched.doConfigureis called any time a device is added with the TYPED provisioning state and is the right place for device-specific configuration commands.infoChangedreceivesargs.old_st_storefor comparing old vs new preferences. Drivers should check if a preference actually changed before acting on it.
5. Key Imports and Require Paths
-- Base driver (for virtual/LAN devices)
local Driver = require "st.driver"
-- Protocol-specific drivers
local ZigbeeDriver = require "st.zigbee"
local ZwaveDriver = require "st.zwave.driver"
local MatterDriver = require "st.matter.driver"
-- Capabilities
local capabilities = require "st.capabilities"
-- Zigbee defaults (pre-built handlers for common capabilities)
local defaults = require "st.zigbee.defaults"
-- Zigbee clusters (for building commands/reading attributes)
local zcl_clusters = require "st.zigbee.zcl"
-- Z-Wave command classes
local cc = require "st.zwave.CommandClass"
local SwitchBinary = require "st.zwave.CommandClass.SwitchBinary"
-- Matter clusters
local clusters = require "st.matter.clusters"
-- Utilities
local utils = require "st.utils"
local json = require "st.json"
local log = require "log"
-- Coroutine runtime
local cosock = require "cosock"
-- LAN utils
local socket = cosock.socket
local luncheon = require "luncheon"
local luxure = require "luxure"
local lustre = require "lustre"
Zigbee driver example (from zigbee-switch)
local capabilities = require "st.capabilities"
local ZigbeeDriver = require "st.zigbee"
local defaults = require "st.zigbee.defaults"
local template = {
supported_capabilities = {
capabilities.switch,
capabilities.switchLevel,
capabilities.colorControl,
capabilities.colorTemperature,
},
sub_drivers = require("sub_drivers"),
lifecycle_handlers = {
init = device_init,
added = device_added,
},
}
-- Register default Zigbee handlers for all supported capabilities
defaults.register_for_default_handlers(template,
template.supported_capabilities,
{native_capability_cmds_enabled = true, native_capability_attrs_enabled = true}
)
local driver = ZigbeeDriver("zigbee_switch", template)
driver:run()
This pattern - declare supported capabilities, register defaults, add overrides via sub_drivers and lifecycle_handlers, then construct and run - is the standard structure for all protocol-based Edge drivers.
6. Default Handlers and Protocol-Specific Default Functionality
When a driver declares supported_capabilities in its template, the framework automatically registers default handlers for each capability. The registration uses or-merge
logic: driver-defined handlers always take precedence over defaults. If the driver already registered a handler for a given cluster/attribute/command slot, the default
is silently skipped.
Registration happens in st.{zigbee,zwave,matter}.defaults.init.lua via register_for_default_handlers(driver, capabilities, opts):
- Iterates
supported_capabilities - For each capability, requires the corresponding defaults module
- Merges
zigbee_handlers,zwave_handlers, ormatter_handlers(only where driver hasn't defined one) - Also merges
attribute_configurations(Zigbee),get_refresh_commands(Z-Wave), orsubscribed_attributes(Matter)
Zigbee specific default functionality
The default doConfigure handler (device_management.configure):
- Sends a
refreshcommand (reads all configured attributes) - Calls
device:configure()which iterates all configured attributes and for each:- Sends a ZDO Bind Request
- Sends a Configure Reporting command with the attribute's min/max interval, data type, and reportable change
- Also handles IAS Zone enrollment if the device supports cluster
0x0500
Z-Wave specific default functionality
doConfigure calls device:default_configure() which calls device:refresh(). The default refresh iterates get_refresh_commands from all default capability modules and sends Get commands for each supported CC.
Refresh collects get_refresh_commands from all default modules, sends Get commands
Matter specific default functionality
TODO
7. Unit Test Framework
Load the testing-edge-drivers skill for details on the built in unit test framework for to test Zigbee, Z-Wave, and Matter drivers.
Source: SmartThingsCommunity/SmartThingsEdgeDrivers — distributed by TomeVault.