summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md80
-rw-r--r--init.lua73
-rw-r--r--sds011.lua74
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