NodeMCU is an interactive firmware, which allows running Lua interpreter on the ESP8266 microcontroller (ESP32 support is in development). Alongside with all the regular hardware interfaces, it has WiFi module and SPIFFS file system.

This article describes the new module for the NodeMCU — sdm. SDM stands for simple driver model and it provides device-driver model abstraction for the system. In the first part of this article we will discuss the model itself and in the second part will be a showcase of dynamically created web user interface using sdm with some commentaries.

Driver model basics

Two major components of the model are devices and drivers. Device is an abstract representation of some hardware or virtual device. It makes sense to place devices into tree hierarchy, with the microcontroller on top, buses in the middle and sensors as leaves.

DEVICES + DRIVERS | +-----+ | +-----+ |1WIRE<----------------------+1WIRE| ++-+-++ | +-----+ | | | | +---------+ | +--------+ | +------+ | | | +------+DS1820| +---v----+ +---v----+ +---v----+ | | +------+ |DS1820|0| |DS1820|1| |DS1822|0| | | +---^----+ +---^----+ +---^----+ | | +------+ | | +--------------+DS1822| | | | | +------+ +-----------+------------------+ +

Device driver is a piece of logic associated with given device. Functions provided by driver are called methods, data containers associated with driver are called attributes. Both methods and attributes live inside driver.

Attributes have two functions associated with them: getter and setter hooks. So attributes superset method functionality, but they also take up more memory (microcontroller memory is scarce, remember?).

sdm.attr_add(drv, -- device handle "ref", -- attribute name "Reference voltage", -- attribute description 5, function(dev) -- this is a getter function return sdm.attr_data(sdm.attr_handle(dev, "ref")) end, function(dev, value) -- this is a setter function sdm.attr_set(sdm.attr_handle(dev, "ref"), value) end )

Device binding

Tricky part of the driver model is device-driver binding. The process itself is quite simple: we match device with each available driver until it fits. Only two parts are missing — matching logic and some data to match to.

In sdm matching logic lives in drivers under the name _poll() . It is a regular method that is called with device handle as parameter and returns true or false if device could or could not be attached to the driver respectively.

sdm.method_add(drv, "_poll", nil, function(dev, drv, par) local attr = sdm.attr_data(sdm.local_attr_handle(dev, "id")) -- get device attribute "id" if attr == nil then return false end -- if it does not have one, driver does not match -- parent name must be "ESP8266_1W" and first byte of "id" must be "0x28" return (sdm.device_name(par) == "ESP8266_1W") and (attr:byte(1) == 0x28) end )

As seen in the example above, driver matches device using attribute. But as noted above, attributes associate only with driver. Generally it is true, but there are some attributes that cannot be retrieved via software. These are chip IDs, used pins etc. For those a special type of attribute was added to the sdm — local attribute. This attribute is associated with one instance of the device and usually immutable.

The only one thing left to say about driver binding. Usually devices require some kind of initialization on startup and cleanup after use. For this purpose sdm uses _init() and _free() methods.

If driver has _init() method then it will be called automatically after device binding. Same with _free() .

sdm.method_add(drv, "_init", nil, function(dev, drv, par) sdm.device_rename(dev, sdm.request_name("DS18B20")) -- rename device sdm.attr_copy(dev, "temp") -- copy attribute sdm.attr_copy(dev, "precision") -- copy attribute local met = sdm.method_dev_handle(par, "setup") -- get 1Wire bus pin init function .. local func = sdm.method_func(met) -- .. and .. func(par, dev) -- .. call it end ) sdm.method_add(drv, "_free", nil, function(dev, drv, par) local met = sdm.method_dev_handle(par, "free") -- get 1Wire bus pin free function .. local func = sdm.method_func(met) -- .. and .. func(par, dev) -- .. call it end )

Attentive reader would probably ask: what does "copy attribute" in the example above mean? And he would be right, because this has to do with the third kind of attribute we have not discussed yet — private attribute. It does not make much sense to have all attribute data shared between all device instances. For this purpose sdm provides mechanism of copying attribute from driver and associate it with device. This makes driver attribute a prototype or template.

A quick summary:

local attributes are used for data which cannot be retrieved by software. Like device IDs, connected pins etc.

driver attributes are used for data shared between all instances of devices attached to this driver.

private attributes are copied from driver attributes and hold data associated with only one device instance. This type is the most common.

Property Local attribute Private attribute Driver (public) attribute Stored in device device driver Accessible using driver handle - - + Accessible using device handle + + + Shared between devices - - + Persist upon driver detach + - +

Web user interface implementation

Server code

There's a lovely nodemcu-httpserver project that implements server code for NudeMCU. Sadly it seems to be dead. It was used as a basis for the server. Firstly, server functions were moved to LFS and then slightly modified to serve one static page for every call. Vue.js is a perfect choice for template based web pages. So it was used for frontend. It worth noting that NodeMCU may not be connected to the Internet. Because of this, vue.js library needs to be present locally and served by NodeMCU server.

Since all devices are organized in tree structure, they are accessed just like a directory: /ESP8266/ESP8266_1W/DS18S20-0 . Here /ESP8266 is a NodeMCU page, /ESP8266/ESP8266_1W is a 1Wire bus page and finally /ESP8266/ESP8266_1W/DS18S20-0 is a temperature sensor.

As mentioned previously, all device pages are build from one template page which is served on every call. JS code inside this page then makes request to the same URL, prepended with /api . For the example above call URL would be /api/ESP8266/ESP8266_1W/DS18S20-0 . On such requests the server responds with JSON-encoded device-specific data, which populates the page. Of course, the HTML page request may be skipped if only raw data is needed.

Device tree

Initial device configuration is done using simple device tree structure. It is like device tree, but simpler. It describes configuration of the hardware including device local attributes.

local root={ -- local_attributes={}, children={ { name="ESP8266_1W", -- local_attributes={}, children = { { name="DS18S20-0", -- static declaration alternative to 1Wire poll method local_attributes={ { name="id", desc=nil, -- empty description to save space data=string.char(16) .. string.char(221) .. string.char(109) .. string.char(104) .. string.char(3) .. string.char(8) .. string.char(0) .. string.char(150) -- ugly way to create byte array }, { datapin=2 } } }, } }, { name="ESP8266_SPI", -- local_attributes={}, children = { { name="MCP3208-0" }, } }, } }

Hardware setup

Here begins the showcase. For this purpose a bunch of sensors were connected to the NodeMCU:

DS18B20 temperature sensor

DS18S20 temperature sensor

MCP3208 ADC

1Wire sensors are connected to the same pin.

Web pages and drivers

root device

The main purpose of the root device (aka ESP8266) is to provide place for its children to connect to. However it's not restricted to have methods or attributes associated with it.

This code snippet is from here:

sdm.method_add(drv, "_init", nil, function(dev, drv, par) local attr = sdm.attr_handle(dev, "id") -- get device "id" attribute sdm.attr_set(attr, node.chipid()) -- set "id" value attr = sdm.attr_handle(dev, "float") -- get device "float" attribute sdm.attr_set(attr, 3 / 2 ~= 1) -- set to true if firmware supports floating point instructions end ) sdm.attr_add(drv, "float", "Floating point build", false, function(drv) -- attribute value is set inside "_init" function local attr = sdm.attr_drv_handle(drv, "float") return sdm.attr_data(attr) -- just return stored value end, nil )

This code adds attribute float which is used to hold firmware build type. Its value is initialized in the _init() hook which is a special function, that runs once when driver attaches to the device.

This is the generated page for the root device.

Here we can see that the root device has one method heap , two driver attributes float and id . Finally, it has two devices connected to it — SPI and 1Wire buses.

SPI

SPI driver is not very interesting. It just maps NodeMCU SPI functions.

MCP3208

MCP3208 is an ADC chip. It measures voltages from zero to ref and returns 12 bit code. What's interesting about this driver implementation is that attribute ref would be present build only if firmware supports floating point arithmetic. If it is not supported then instead of absolute voltage, voltage code is returned by both single and differential methods.

sdm.method_add(drv, "single", "Single ended measure 0|1|2|3|4|5|6|7", function(dev, channel) -- ... if ref ~= nil then -- this part is executed only if floating point arithmetic is enabled rv = ref * rv / 4096 end return rv end ) if 3/2~=1 then -- other alternative is to access ESP8266 "float" method sdm.attr_add(drv, "ref", "Reference voltage", 5, function(dev) return sdm.attr_data(sdm.attr_handle(dev, "ref")) end, function(dev, value) sdm.attr_set(sdm.attr_handle(dev, "ref"), value) end ) end

Also note that this device has attribute ref marked as private. It is set on per-device basis.

1Wire

1Wire driver implements poll method — dynamic search for devices.

Right after device discovery its type is not known. So its 1Wire unique address is used as a new device name (bytes represented as numbers separated by _ character).

sdm.method_add(drv, "poll", "Poll for devices", function(bus, pin) local children = sdm.device_children(bus) or {} -- already attached local ids = {} -- get IDs of attached devices for name, handle in pairs(children) do local dpin = sdm.attr_data(sdm.local_attr_handle(handle, "pin")) if dpin == pin then ids[sdm.attr_data(sdm.local_attr_handle(handle, "id"))] = true end end ow.reset_search(pin) -- reset previous search while true do -- for all found devices local id = ow.search(pin) if id == nil then break end if ids[id] == nil then -- if not already present local name = "" for i=1,#id do name = name .. tostring(id:byte(i)) .. "_" end name = name:sub(1,-2) -- add to system with their ID used as name local device = sdm.device_add(name, bus) -- add "pin" attribute local rv = sdm.local_attr_add(device, "datapin", nil, pin, nil, nil) -- add "id" attribute local rv = sdm.local_attr_add(device, "id", nil, id, nil, nil) -- poll for driver local rv = sdm.device_poll(device) end end end )

This is the initial page for 1Wire driver.

After issuing poll call with argument 2 and refreshing page, children section appears. Note that children names are human readable. This is because device_rename() function was called during their _init .

DS18S20

Upon initialization, DS18S20 driver checks that device ID begins with 0x10 , which is a device family code. When device is attached to driver, it is renamed to the DS18S20-X , where DS18S20 is a basename and X is an instance number.

sdm.method_add(drv, "_poll", nil, function(dev, drv, par) local attr = sdm.attr_data(sdm.local_attr_handle(dev, "id")) if attr == nil then return false end return (sdm.device_name(par) == "ESP8266_1W") and (attr:byte(1) == 0x10) -- check family ID end ) sdm.method_add(drv, "_init", nil, function(dev, drv, par) sdm.device_rename(dev, sdm.request_name("DS18S20")) -- rename device sdm.attr_copy(dev, "temp") -- copy attribute to device local met = sdm.method_dev_handle(par, "setup") local func = sdm.method_func(met) -- use parent "setup" method on the device func(par, dev) end )

Local attributes id and datapin do not have getter and setter hooks, so only their names are visible.

DS18B20

DS18B20 driver is almost the same as DS18S20 driver. The only difference is the precision method. Both DS18?20 drivers assume integer build and do not use floating point division.

sdm.attr_add(drv, "precision", "Precision (9|10|11|12)", 12, function(dev, precision) local attr = sdm.attr_dev_handle(dev, "precision") return sdm.attr_data(attr) end, function(dev, precision) local par = sdm.device_parent(dev) local attr = sdm.attr_dev_handle(dev, "precision") local ex = sdm.method_func(sdm.method_dev_handle(par, "exchange")) local modes = {[9]=0x1f, [10]=0x3f, [11]=0x5f, [12]=0x7f} if modes[precision] ~= nil then ex(par, dev, {0x4e, 0, 0, modes[precision]}) sdm.attr_set(attr, precision) end end )

Memory usage

ESP8266 free memory is about 40k. Server code is moved to LFS, so it does not take any RAM space at initialization time (original code took about 10k).

SDM takes up about 10k for 5 device drivers and 5 devices. Slightly lesser for non-floating firmware build. So it's preferable to select in driver manifest only drivers needed for the task at hand. The most memory consuming task is to serve vue.js library.

In case of requesting raw JSON-encoded data (using curl ) peak memory consumption may be significantly reduced.

Instead of an Epilogue