diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 80 | ||||
-rw-r--r-- | init.lua | 73 | ||||
-rw-r--r-- | sds011.lua | 74 |
4 files changed, 228 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..338c30b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..d37441f --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# ESP8266 Lua/NodeMCU module for SDS011 particle monitor + +This repository contains a Lua module (`sds011.lua`) as well as ESP8266/NodeMCU +MQTT gateway application example (`init.lua`) for the **SDS011** particulate +matter (PM2.5 and PM10) sensor. + +## Dependencies + +sds011.lua has been tested with Lua 5.1 on NodeMCU firmware 3.0.1 +(Release 202112300746, integer build). It requires the following modules. + +* struct + +Most practical applications (such as the example in init.lua) also need the +following modules. + +* gpio +* mqtt +* node +* softuart +* tmr +* uart +* wifi + +## Setup + +Connect the SDS011 sensor to your ESP8266/NodeMCU board as follows. + +* SDS011 GND → ESP8266/NodeMCU GND +* SDS011 5V → 5V input (note that the "5V" pin of NodeMCU or D1 mini dev boards is connected to its USB input via a protective diode, so when powering the board via USB the "5V" output is more like 4.7V. I have not tested whether that is an issue) +* SDS011 TXD → NodeMCU D1 (ESP8266 GPIO5) +* SDS011 RXD → NodeMCU D2 (ESP8266 GPIO4) + +If you use use different pins for TXD and RXD, you need to adjust the +softuart.setup call in the examples provided in this repository to reflect +those changes. Keep in mind that some ESP8266 pins must have well-defined logic +levels at boot time and may therefore be unsuitable for SDS011 connection. + +## Usage + +Copy **sds011.lua** to your NodeMCU board and set it up as follows. + +```lua +sds011 = require("sds011") +port = softuart.setup(9600, 2, 1) +port:on("data", 10, uart_callback) + +function uart_callback(data + local pm25i, pm25d, pm10i, pm10d = sds011.parse_frame(data) + if pm25i ~= nil then + -- pm25i/pm10i contain the integer part (i.e., PM2.5 / PM10 value in µg/m³) + -- pm25d/pm10d contain the decimal/fractional part (i.e., PM2.5 / PM10 fraction in .1 µg/m³, range 0 .. 9) + else + -- invalid checksum or non-data frame (i.e., acknowledgment of a write command) + end +end +``` + +See **init.lua** for an example. To use it, you need to create a **config.lua** file with WiFI and MQTT settings: + +```lua +station_cfg.ssid = "..." +station_cfg.pwd = "..." +mqtt_host = "..." +``` + +## SDS011 Configuration API + +If desired, **sds011.lua** can be used to configure the SDS011 sensor. +Currently, the following commands are supported + +* `sds011.set_report_mode(active)` + * active == true: periodically report PM2.5 and PM10 values via UART + * active == false: only report PM2.5 and PM10 values when queried +* `sds011.sleep(sleep)` + * sleep == true: put sensor into sleep mode. The fan is turned off, no further measurements are performed + * sleep == false: wake up sensor. +* `sds011.set_work_period(period)` + * period == 0: continuous operation (about one measurement per second) + * 0 < *period* ≤ 30: about one measurement every *period* minutes; fan turned off in-between diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..e9abef9 --- /dev/null +++ b/init.lua @@ -0,0 +1,73 @@ +station_cfg = {} +dofile("config.lua") + +delayed_restart = tmr.create() +chipid = node.chipid() +mqtt_prefix = "sensor/esp8266_" .. chipid +mqttclient = mqtt.Client("esp8266_" .. chipid, 120) + +print("ESP8266 " .. chipid) + +ledpin = 4 +gpio.mode(ledpin, gpio.OUTPUT) +gpio.write(ledpin, 0) + +sds011 = require("sds011") + +function log_restart() + print("Network error " .. wifi.sta.status() .. ". Restarting in 20 seconds.") + delayed_restart:start() +end + +function setup_client() + gpio.write(ledpin, 1) + publishing = true + mqttclient:publish(mqtt_prefix .. "/state", "online", 0, 1, function(client) + publishing = false + end) + port = softuart.setup(9600, 2, 1) + port:on("data", 10, uart_callback) +end + +function connect_mqtt() + print("IP address: " .. wifi.sta.getip()) + print("Connecting to MQTT " .. mqtt_host) + delayed_restart:stop() + mqttclient:on("connect", setup_client) + mqttclient:on("offline", log_restart) + mqttclient:lwt(mqtt_prefix .. "/state", "offline", 0, 1) + mqttclient:connect(mqtt_host) +end + +function connect_wifi() + print("WiFi MAC: " .. wifi.sta.getmac()) + print("Connecting to ESSID " .. station_cfg.ssid) + wifi.eventmon.register(wifi.eventmon.STA_GOT_IP, connect_mqtt) + wifi.eventmon.register(wifi.eventmon.STA_DHCP_TIMEOUT, log_restart) + wifi.eventmon.register(wifi.eventmon.STA_DISCONNECTED, log_restart) + wifi.setmode(wifi.STATION) + wifi.sta.config(station_cfg) + wifi.sta.connect() +end + +function uart_callback(data) + local pm25i, pm25f, pm10i, pm10f = sds011.parse_frame(data) + if pm25i == nil then + print("Invalid or data-less SDS011 frame") + return + end + local json_str = string.format('{"pm25_ugm3": %d.%d, "pm10_ugm3": %d.%d, "rssi_dbm": %d}', pm25i, pm25f, pm10i, pm10f, wifi.sta.getrssi()) + if not publishing then + publishing = true + gpio.write(ledpin, 0) + mqttclient:publish(mqtt_prefix .. "/data", json_str, 0, 0, function(client) + publishing = false + gpio.write(ledpin, 1) + collectgarbage() + end) + end +end + +delayed_restart:register(20 * 1000, tmr.ALARM_SINGLE, node.restart) + +connect_wifi() diff --git a/sds011.lua b/sds011.lua new file mode 100644 index 0000000..a528720 --- /dev/null +++ b/sds011.lua @@ -0,0 +1,74 @@ +local sds011 = {} + +local c_head = 0xaa +local c_tail = 0xab +local c_id = 0xb4 + +local c_read = 0x00 +local c_write = 0x01 + +local c_report_mode = 0x02 +local c_active = 0x00 +local c_passive = 0x01 + +local c_query = 0x04 + +local c_sleepcmd = 0x06 +local c_sleep = 0x00 +local c_work = 0x01 +local c_workperiod = 0x08 + +function sds011.finish_cmd(cmd) + cmd = cmd .. string.char(0xff, 0xff) + local checksum = 0 + for i = 3, string.len(cmd) do + checksum = (checksum + string.byte(cmd, i)) % 256 + end + cmd = cmd .. string.char(checksum, c_tail) + return cmd +end + +function sds011.set_report_mode(active) + local cmd = string.char(c_head, c_id, c_report_mode, c_write) + if active then + cmd = cmd .. string.char(c_active) + else + cmd = cmd .. string.char(c_passive) + end + cmd = cmd .. string.char(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + return sds011.finish_cmd(cmd) +end + +function sds011.sleep(sleep) + local cmd = string.char(c_head, c_id, c_sleepcmd, c_write) + if sleep then + cmd = cmd .. string.char(c_sleep) + else + cmd = cmd .. string.char(c_work) + end + cmd = cmd .. string.char(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + return sds011.finish_cmd(cmd) +end + +function sds011.set_work_period(period) + -- period == 0 : continuous operation, about one measurement per second + -- period > 0 : about one measurement every <period> minutes, fan is turned off in-between + if period < 0 or period > 30 then + return + end + local cmd = string.char(c_head, c_id, c_workperiod, c_write, period) + cmd = cmd .. string.char(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + return sds011.finish_cmd(cmd) +end + +function sds011.parse_frame(data) + local header, command, pm25l, pm25h, pm10l, pm10h, id1, id2, sum, tail = struct.unpack("BBBBBBBBBB", data) + if header ~= c_head or command ~= 0xc0 or (pm25l + pm25h + pm10l + pm10h + id1 + id2) % 256 ~= sum or tail ~= c_tail then + return nil + end + pm25 = pm25h * 256 + pm25l + pm10 = pm10h * 256 + pm10l + return pm25 / 10, pm25 % 10, pm10 / 10, pm10 % 10 +end + +return sds011 |