Saturday, November 5, 2016

Cayenn RPM Sensor for ChiliPeppr Using Nodemcu ESP8266 / Hall Effect Sensor

This blog post describe the creation of an RPM hall effect sensor for the Cayenn protocol for ChiliPeppr.

It runs from a NodeMCU ESP8266.



A Hall Effect Sensor is connected to the ESP8266 to port 2. The actual sensor will eventually be mounted correctly from a plastic part.

Back side of hall effect sensor. Only 3.3v power and the digital out port are used.



The spindle head has a magnet attached on the inside of it.
Video of the sensor in action.


Oscilloscope Output

When the spindle head is running, pulses are generated by the hall effect sensor. The pulses are sent to pin 2 of the ESP8266 so it can count the rotations of the spindle.

1K RPM

When running spindle at 1K RPM. The yellow lines are the direct pulses from the hall effect sensor. The voltage is 3.3v and is low for only about 2% of the time because the magnet is very small compared to the overall circumference of the spindle plastic head. This makes detection somewhat difficult because the pulse is only low when the magnet is directly in front of the hall effect sensor.



The blue pulses are debug output on port 3 of the ESP8266. Each high/low indicates 2 counts of the spindle rotating. The high/low output made it much easier to debug on the oscilloscope to ensure counts were accurate. As seen in the screen grab above it is 100% accurate.

5K RPM

When running spindle at 5K RPM. The blips on the blue line are when the ESP8266 transmits the UDP packets. The RPM is not counted during that period. Per the screenshot below, this is a 100% exact capture of RPM count.


10K RPM

When running spindle at 10K RPM. Keep in mind the yellow pulses are just not all showing due to anti-aliasing in the oscilloscope software.


Asking RPM Sensor to Start Sending

Once the ESP8266 is powered on, it connects to Wifi and announces its existence on the network. Per the Cayenn protocol, it sends the following announcement to the broadcast network address, thus reaching SPJS, thus subsequently reaching ChiliPeppr (if you're connected to SPJS).

{
  "Addr":{
    "IP":"10.0.0.169",
    "Port":3739,
    "Network":"udp"
  },
  "Announce":"i-am-a-client",
  "Widget":"com-chilipeppr-widget-rpmsensor",
  "JsonTag":"{\"Icon\":\"http:\\/\\/gds-storage-prd.s3.amazonaws.com\\/fusion-360\\/161021\\/1972\\/70f0aa71\\/thumbnails\\/raasrendering-bbf3b3d5-01c5-48e4-bbdb-f81727a520ab-160-160.jpg\",\"Name\":\"Spindle RPM\",\"WidgetUrl\":\"https:\\/\\/github.com\\/chilipeppr\\/widget-dispenser\\/auto-generated.html\",\"Widget\":\"com-chilipeppr-widget-rpmsensor\",\"Desc\":\"Counts spindle RPM using LED tachometer\"}",
  "DeviceId":"chip:8395871-flash:1458400-mac:5c:cf:7f:80:1c:5f"
}
You can see this in the SPJS widget by choosing Show / Hide Console in the upper right corner triangle menu.

The JSON announce packet is highlighted in yellow below to show you where it shows up in the console.

Request Announcement

You can also request to discover all Cayenn devices if you want them to announce themselves again. Send the following command to SPJS at any time. Take note this is a UDP command.
cayenn-sendudp 10.0.0.255 {"Cayenn":"Discover"}
 

Start RPM Sensing

You will get back all discovered devices. When you find your RPM sensor, and thus its IP address, you can ask it to start sending RPM readings. You will need to send the following command to SPJS. Take note this is a TCP command. Ensure the IP address is the address of your device.
cayenn-sendtcp 10.0.0.169 {"Cmd":"RpmSenseStart"}

UDP Packets Sent from ESP8266 to SPJS to ChiliPeppr

When the RPM sensor starts to provide data, it will come in packets like those below. Notice that the ESP8266 sends the RPM update packets via UDP out to the network broadcast address. This makes the sending efficient because TCP may have to retransmit packets. Since RPM data is very fleeting, it doesn't matter if a packet gets dropped occasionally by the Wifi network.

The actual RPM value comes in as a JsonTag which is the part of the Cayenn protocol that allows random tags of any type, and it is simply up to the final widget to interpret the data. Notice that a unique DeviceID is also transmitted each time to assist ChiliPeppr with uniquely recognizing each Cayenn device.

{
  "Addr":{
    "IP":"10.0.0.169",
    "Port":7366,
    "Network":"udp"
  },
  "Announce":"",
  "Widget":"",
  "JsonTag":"{\"Rpm\":1020}",
  "DeviceId":"chip:8395871-flash:1458400-mac:5c:cf:7f:80:1c:5f"
}

RPM Widget in ChiliPeppr

Once ChiliPeppr recognizes the existence of the hall effect sensor, it will dynamically load the Javascript for the widget into memory. The widget will be initted and auto appear inside ChiliPeppr. This makes configuring your workspace easy.

Here is what the widget looks like in the TinyG workspace in ChiliPeppr.


Lua Code

The Lua code consists of several modules.
  • init.lua
  • main_rpmsensor.lua
  • rpmsensorv2.lua
  • cayennv2.lua
  • led.lua

init.lua - auto loading file when ESP8266 boots

-- Init file
-- Simply run the main compiled file for this Cayenn device
dofile("main_rpmsensor.lc")

main_rpmsensor.lua - main entry point

-- RPM Sensor
-- Supports Cayenn announcement and UDP data sending of RPM data

-- run fast to gather rpm counters
-- node.setcpufreq(node.CPU160MHZ)

cayenn = require('cayennv2')
rpmsensor = require('rpmsensorv2')
led = require("led")

opts = {}
opts.Name = "Spindle RPM"
opts.Desc = "Counts spindle RPM using LED tachometer"
opts.Icon = "http://gds-storage-prd.s3.amazonaws.com/fusion-360/161021/1972/70f0aa71/thumbnails/raasrendering-bbf3b3d5-01c5-48e4-bbdb-f81727a520ab-160-160.jpg"
opts.Widget = "com-chilipeppr-widget-rpmsensor"
opts.WidgetUrl = "https://github.com/chilipeppr/widget-dispenser/auto-generated.html"

-- opts = nil -- dealloc opts

-- define commands supported
cmds = {
  "ResetCtr", "GetCmds", "GetQ", "WipeQ", "CmdQ", 
  "RpmSenseStart", "RpmSenseStop"
  }

queue = {}

-- this is called by RpmSensor each second when the new RPM count 
-- is known.
function onRpmRead(ctr)
  -- print("Got RPM value from RPM sensor: " .. ctr)

  -- led inidcator we sent
  led.on()
  
  -- stop rpm watching to try to get more accurate count
  -- because sending UDP chews up cpu, so interrupts get weird
  -- during that time
  rpmsensor.stopRpmWatch()

  -- send UDP msg to SPJS
  local rpm = {}
  rpm.Rpm = ctr
  cayenn.sendBroadcast(rpm)
  
  led.off()
  
  -- start watching interrupts again
  rpmsensor.startRpmWatch()
end

-- this is called by Cayenn when an incoming cmd comes in
-- from the network, i.e. from SPJS. These are TCP commands so
-- they are guaranteed to come in (vs UDP which could drop)
function onCmd(payload)
  
  if (type(payload) == "table") then
      -- print("is json")
      -- print("Got incoming Cayenn cmd JSON: " .. cjson.encode(payload))
      
      -- See what cmd
      if payload.Cmd == "ResetCtr" then
        cnc.resetIdCounter()
      elseif payload.Cmd == "GetCmds" then
        cayenn.sendAnnounceBroadcast(cmds)
      elseif payload.Cmd == "GetQ" then
        -- print("Sending queue back to network")
        cayenn.sendAnnounceBroadcast(queue)
      elseif payload.Cmd == "WipeQ" then
        queue = nil
        -- print("Wiped queue: " .. cjson.encode(queue))
      elseif payload.Cmd == "CmdQ" then
        -- queuing cmd. we must have ID.
        if payload.Id == nil then
          print("Error queuing command. It must have an ID")
          return
        end
        if payload.RunCmd == nil then
          print("Error queuing command. It must have a RunCmd like RunCmd:{Cmd:AugerOn,Speed:10}.")
          return
        end
        -- print("Queing command")
        queue[payload.Id] = payload.RunCmd
        -- print("New queue: " .. cjson.encode(queue))
        
      elseif payload.Cmd == "RpmSenseStart" then
        rpmsensor.startRpmWatch()
        print("Start sensing RPM and sending updates each second")
      elseif payload.Cmd == "RpmSenseStop" then
        rpmsensor.stopRpmWatch()
        print("Stop sensing RPM and sending updates each second")
      else
        print("Got cmd we do not understand. Huh?")
      end
  else
      -- print("is string")
      -- print("Got incoming Cayenn cmd. str: ", payload)
  end
end

-- this callback is called when an incoming UDP broadcast 
-- comes in to this device. typically this is just for 
-- Cayenn Discover requests to figure out what devices are on 
-- the network
function onIncomingBroadcast(cmd)
  -- print("Got incoming UDP cmd: ", cmd)
  if (type(cmd) == "table") then
    if cmd["Cayenn"] ~= nil then
      if cmd.Cayenn == "Discover" then
        -- somebody is asking me to announce myself
        cayenn.sendAnnounceBroadcast()
      else
        print("Do not understand incoming Cayenn cmd")
      end
    elseif cmd["Announce"] ~= nil then
      if cmd.Announce == "i-am-your-server" then
        print("Got a server announcement. Cool. Store it. TODO")
      else 
        print("Got announce but not from a server. Huh?")
      end 
    else 
      print("Do not understand incoming UDP cmd")
    end
    
  else 
    print("Got incoming UDP as string")
  end
end

-- add listener to incoming cayenn commands
cayenn.addListenerOnIncomingCmd(onCmd)
cayenn.addListenerOnIncomingUdpCmd(onIncomingBroadcast)
-- add listener to RPM Known
rpmsensor.addListenerOnRpmKnown(onRpmRead)

cayenn.init(opts)
rpmsensor.init()

led.blink(6)

rpmsensorv2.lua - module that reads the interrupts from the hall sensor

-- Read the inputs from RPM Sensor
-- We need to watch D2 Pin

local m = {}
-- m = {}

node.setcpufreq(node.CPU160MHZ)
-- node.setcpufreq(node.CPU80MHZ)

m.pinRpm = 2

-- global  counter. we increment this each time we see 
-- a signal on the rpm sensor pin 
m.counter = 0

function m.init()
  -- gpio.mode(m.pinCoolant, gpio.INPUT)
  gpio.mode(m.pinRpm, gpio.INT) --, gpio.PULLUP)
  gpio.mode(3, gpio.OUTPUT)
  -- gpio.trig(m.pinRpm, "up", m.pinRpmCallback)
  gpio.trig(m.pinRpm, "up", m.pinRpmCallback)
  print("Setup pin watcher for RPM sensor. pin: " .. m.pinRpm)
  -- m.startRpmWatch()
  m.status()
end

function m.startRpmWatch()
  m.counter = 0
  tmr.alarm(0, 1000, tmr.ALARM_AUTO, m.onTimer)
end

function m.stopRpmWatch()
  tmr.stop(0)
end

function m.onTimer()
  tmr.stop(0)
  -- print("RPM: " .. m.counter * 60)
  m.onRpmKnown(m.counter * 60)
  m.counter = 0
  tmr.start(0)
end

function m.status()
  print("RPM Pin: " .. tostring(gpio.read(m.pinRpm)))
end

m.lastTime = 0
function m.pinRpmCallback(level)
  
  -- make sure we got a callback later than our min time
  if tmr.now() - m.lastTime > 3000 then
    if m.isHigh then
      gpio.write(3, gpio.LOW)
      m.isHigh = false
    else
      gpio.write(3, gpio.HIGH)
      m.isHigh = true
    end
    m.counter = m.counter + 1
  end

  -- if they don't start the counter then just reset when too high
  if m.counter > 1000 then
    m.counter = 0
  end

  m.lastTime = tmr.now()
  
  gpio.trig(m.pinRpm, "up")
end

m.isHigh = false
function m.pinRpmCallbackOrig(level)
  if level == 1 then
    m.counter = m.counter + 1
    gpio.trig(m.pinRpm, "up")
    if m.isHigh then 
      gpio.write(3, gpio.LOW)
      m.isHigh = false
    else
      gpio.write(3, gpio.HIGH)
      m.isHigh = true
    end
  end
end

function m.resetCounter()
  m.counter = 0
  -- m.onIdChange()
  print("Reset counter: " .. m.counter)
end

function m.getCounter()
  return m.counter
end 

-- this property and method let an external object attach a
-- listener to the counter change
m.listenerOnRpmKnown = null
function m.addListenerOnRpmKnown(listenerCallback)
  m.listenerOnRpmKnown = listenerCallback
  print("Attached listener to RPM Known")
end

function m.removeListenerOnRpmKnown(listenerCallback)
  m.listenerOnRpmKnown = null
  print("Removed listener on RPM Known")
end

function m.onRpmKnown(ctr)
  if m.listenerOnRpmKnown then
    m.listenerOnRpmKnown(ctr)
  end
end

return m
-- m.init()

cayennv2.lua - standard Cayenn module providing UDP/TCP communications

-- Cayenn Protocol for ChiliPeppr
-- This module does udp/tcp sending/receiving to talk with SPJS
-- or have the browser talk direct to this ESP8266 device.
-- This module has methods for connecting to wifi, then initting
-- UDP servers, TCP servers, and sending a broadcast announce out.
-- The broadcast announce let's any listening SPJS know we're alive.
-- SPJS then reflects that message to any listening browsers like
-- ChiliPeppr so they know a device is available on the network.
-- Then the browser can send commands back to this device.
local M = {}
-- M = {}

M.port = 8988
M.myip = nil
M.sock = nil
M.jsonTagTable = nil

--M.announce = 

M.isInitted = false

-- When you are initting you can pass in tags to describe your device
-- You should use a format like this:
-- cayenn.init({
--  widget: "com-chilipeppr-widget-ina219",
--  icon: "http://chilipeppr.com/img/ina219.jpg",
--  widgetUrl: "https://github.com/chilipeppr/widget-ina219/auto-generated.html",
-- })
function M.init(jsonTagTable)
  
  -- save the jsonTagTable
  if jsonTagTable ~= nil then
    M.jsonTagTable = jsonTagTable
  end
  
  if M.isInitted then
    print("Already initted, but got called again")
    return
  end
  
  print("Initting...")

  -- figure out if i have an IP
  M.myip = wifi.sta.getip()
  if M.myip == nil then
    print("You need to connect to wifi. Connecting for you.")
    M.setupWifi()
  else 
    print("My IP: " .. M.myip)
    M.isInitted = true

    -- create socket for outbound UDP sending
    M.sock = net.createConnection(net.UDP, 0)

    -- create server to listen to incoming udp
    M.initUdpServer()
    
    -- create server to listen to incoming tcp
    M.initTcpServer()
    
    -- send our announce
    M.sendAnnounceBroadcast(M.jsonTagTable)
  
  end 
  
end

function M.createAnnounce(jsonTagTable)
  
  local a = {}
  a.Announce = "i-am-a-client"
  a.MyDeviceId = "chip:" .. node.chipid() .. "-flash:" .. node.flashid() .. "-mac:" .. wifi.sta.getmac()
  
  if jsonTagTable.Name then
    a.Name = jsonTagTable.Name
    -- jsonTagTable.Name = nil -- erase from table so not in jsonTags
  elseif M.jsonTagTable.Name then
    a.Name = M.jsonTagTable.Name
  else
    a.Name = "Unnamed"
  end
  
  if jsonTagTable.Desc then
    a.Desc = jsonTagTable.Desc
    -- jsonTagTable.Desc = nil -- erase from table so not in jsonTags
  else
    a.Desc = "(no desc provided)"
  end
  
  if jsonTagTable.Icon then
    a.Icon = jsonTagTable.Icon
    -- jsonTagTable.Icon = nil -- erase from table so not in jsonTags
  else
    a.Icon = "(no icon provided)"
  end
  
  if jsonTagTable.Widget then
    a.Widget = jsonTagTable.Widget
    -- jsonTagTable.Widget = nil
  elseif M.jsonTagTable.Widget then
    a.Widget = M.jsonTagTable.Widget
  else
    a.Widget = "com-chilipeppr-widget-undefined"
  end
  
  if jsonTagTable.WidgetUrl then
    a.WidgetUrl = jsonTagTable.WidgetUrl
    -- jsonTagTable.WidgetUrl = nil
  else
    a.WidgetUrl = "(no url specified)"
  end
  
  -- see if there is a jsontagtable passed in as extra meta
  local jsontag = ""
  if jsonTagTable then
    ok, jsontag = pcall(cjson.encode, jsonTagTable)
    if ok then
      -- print("Adding jsontagtable" .. jsontag)
    else
      print("failed to encode jsontag!")
    end
  end

  a.JsonTag = jsontag
  
  local ok, json = pcall(cjson.encode, a)
  if ok then
    --print("Encoded json for announce: " .. json)
  else
    print("failed to encode json!")
  end
  print("Announce msg: " .. json)
  return json
end

-- send announce to broadcast addr so spjs 
-- knows of our existence
function M.sendAnnounceBroadcast(jsonTagTable)
  if M.isInitted == false then
    print("You must init first.")
    return 
  end
  
  local bip = wifi.sta.getbroadcast()
  --print("Broadcast addr:" .. bip)
  
  -- if there was no jsonTagTable passed in, then used 
  -- stored one 
  if not jsonTagTable then 
    jsonTagTable = M.jsonTagTable
  end 
  
  print("Sending announce to ip: " .. bip)
  M.sock:connect(M.port, bip)
  M.sock:send(M.createAnnounce(jsonTagTable))
  M.sock:close()
  
end

function M.sendBroadcast(jsonTagTable)
  if M.isInitted == false then
    print("You must init first.")
    return 
  end
  
  -- we need to attach deviceid 
  local a = {}
  a.MyDeviceId = "chip:" .. node.chipid() .. "-flash:" .. node.flashid() .. "-mac:" .. wifi.sta.getmac()

  -- see if there is a jsontagtable passed in as extra meta
  local jsontag = ""
  if jsonTagTable then
    ok, jsontag = pcall(cjson.encode, jsonTagTable)
    if ok then
      -- print("Adding jsontagtable" .. jsontag)
    else
      print("failed to encode jsontag!")
    end
  end

  a.JsonTag = jsontag
  
  local ok, json = pcall(cjson.encode, a)
  if ok then
    --print("Encoded json for announce: " .. json)
  else
    print("failed to encode json!")
  end
  
  local bip = wifi.sta.getbroadcast()
  --print("Broadcast addr:" .. bip)
  
  -- local msg = cjson.encode(jsonTagTable)
  -- local msg = cjson.encode(a)
  -- print("Sending msg: " .. json .. " to ip: " .. bip)
  M.sock:connect(M.port, bip)
  M.sock:send(json)
  M.sock:close()
  
end

function M.setupWifi()
  -- setwifi
  wifi.setmode(wifi.STATION)
  -- longest range is b
  wifi.setphymode(wifi.PHYMODE_N)
  --Connect to access point automatically when in range
  
  -- for some reason digits in password seem to get mangled
  -- so splitting them seems to solve problem
  wifi.sta.config("NETGEAR-main", "yourpassword")
  wifi.sta.connect()
  
  --register callback
  wifi.sta.eventMonReg(wifi.STA_IDLE, function() print("STATION_IDLE") end)
  --wifi.sta.eventMonReg(wifi.STA_CONNECTING, function() print("STATION_CONNECTING") end)
  wifi.sta.eventMonReg(wifi.STA_WRONGPWD, function() print("STATION_WRONG_PASSWORD") end)
  wifi.sta.eventMonReg(wifi.STA_APNOTFOUND, function() print("STATION_NO_AP_FOUND") end)
  wifi.sta.eventMonReg(wifi.STA_FAIL, function() print("STATION_CONNECT_FAIL") end)
  wifi.sta.eventMonReg(wifi.STA_GOTIP, M.gotip)
  
  --register callback: use previous state
  wifi.sta.eventMonReg(wifi.STA_CONNECTING, function(previous_State)
      if(previous_State==wifi.STA_GOTIP) then 
          print("Station lost connection with access point\n\tAttempting to reconnect...")
      else
          print("STATION_CONNECTING")
      end
  end)
  
  --start WiFi event monitor with default interval
  wifi.sta.eventMonStart(1000)
  
end

function M.gotip()
  print("STATION_GOT_IP")
  M.myip = wifi.sta.getip()
  print("My IP: " .. M.myip)
  -- stop monitoring now since we're connected
  wifi.sta.eventMonStop()
  print("Stopped monitoring wifi events since connected.")
  -- make sure we are initted
  M.init()
end

function M.initUdpServer()
  M.udpServer = net.createServer(net.UDP)
  --M.udpServer:on("connection", M.onUdpConnection)
  M.udpServer:on("receive", M.onUdpRecv) 
  M.udpServer:listen(8988)
  print("UDP Server started on port 8988")
end

function M.onUdpConnection(sck)
  print("UDP connection.")
  --ip, port = sck:getpeer()
  --print("UDP connection. from: " .. ip)
end

function M.onUdpRecv(sck, data)
  print("UDP Recvd. data: " .. data)
  if (M.listenerOnIncomingUdpCmd) then
    -- see if json
    if string.sub(data,1,1) == "{" then
      -- catch json errors
      local succ, results = pcall(function()
       return cjson.decode(data)
      end)
      
      -- see if we could parse
      if succ then
       data = results --cjson.decode(data)
        -- data.peerIp = peer
      else
       print("Error parsing JSON")
       return
      end
      
    end
    M.listenerOnIncomingUdpCmd(data)
  end
end

-- this property and method let an external object attach a
-- listener to the incoming UDP cmd
M.listenerOnIncomingUdpCmd = nil
function M.addListenerOnIncomingUdpCmd(listenerCallback)
  M.listenerOnIncomingUdpCmd = listenerCallback
  print("Attached listener to incoming UDP cmd")
end

function M.removeListenerOnIncomingUdpCmd(listenerCallback)
  M.listenerOnIncomingUdpCmd = nil
  print("Removed listener on incoming UDP cmd")
end

function M.initTcpServer()
  M.tcpServer = net.createServer(net.TCP)
  M.tcpServer:listen(8988, M.onTcpListen)
  
  print("TCP Server started on port 8988")
end

function M.onTcpListen(conn)
  conn:on("receive", M.onTcpRecv)
end

function M.onTcpConnection(sck)
  print("TCP connection.")
  --ip, port = sck:getpeer()
  --print("UDP connection. from: " .. ip)
end

function M.onTcpRecv(sck, data)
  local peer = sck:getpeer()
  print("TCP Recvd. data: " .. data .. ", Peer:" .. peer)
  if (M.listenerOnIncomingCmd) then
    -- see if json
    if string.sub(data,1,1) == "{" then
      -- catch json errors
      local succ, results = pcall(function()
       return cjson.decode(data)
      end)
      
      -- see if we could parse
      if succ then
       data = results --cjson.decode(data)
        data.peerIp = peer
      else
       print("Error parsing JSON")
       return
      end
      
    end
    M.listenerOnIncomingCmd(data)
  end
end

-- this property and method let an external object attach a
-- listener to the incoming TCP command
M.listenerOnIncomingCmd = nil
function M.addListenerOnIncomingCmd(listenerCallback)
  M.listenerOnIncomingCmd = listenerCallback
  print("Attached listener to incoming TCP cmd")
end

function M.removeListenerOnIncomingCmd(listenerCallback)
  M.listenerOnIncomingCmd = nil
  print("Removed listener on incoming TCP cmd")
end

return M

--M.init()

led.lua - utility module to blink ESP8266 led easily

-- control led on esp8266
-- use this module like
-- led = require("led")
-- led.on() -- turn on led
-- led.off() -- turn off led
-- led.blink(2) -- blink twice w/ 200ms delay
-- led.blink(6,100) -- blink 6 times w/ 100ms delay
-- led.blink(10,20) -- blink 10 times w/ 20ms delay

local led = {}
led = {}

led.pin = 4 -- this is GPIO2
led.timerId = 6  -- 0 thru 6 allowed. change to not conflict.
led.isOn = false
led.isInitted = false

function led.init()
  -- setup led as output and turn on
  gpio.mode(led.pin, gpio.OUTPUT)
  gpio.write(led.pin, gpio.HIGH)
  led.isOn = false
  led.isInitted = true
end

function led.on()
  if led.isInitted ~= true then
    led.init()
  end
  
  gpio.write(4, gpio.LOW) --on
  -- print("Led on")
end

function led.off()
  if led.isInitted ~= true then
    led.init()
  end
  
  gpio.write(4, gpio.HIGH) --off
  -- print("Led off")
end

led.blinkTimes = 0
led.delay = 200
function led.blink(val, delay)
  led.blinkTimes = val * 2
  if delay ~= nil and delay > 0 then
    led.delay = delay
  end
  tmr.alarm(led.timerId, led.delay, tmr.ALARM_AUTO, led.onTimer)
end

led.ctr = 0
function led.onTimer()
  
  led.ctr = led.ctr + 1
  
  if led.ctr > led.blinkTimes then 
    tmr.stop(led.timerId)
    led.ctr = 0 
    led.off()
    -- print("Done")
    led.delay = 200 --reset delay to default
    return
  end 
  
  if gpio.read(4) == gpio.HIGH then
    led.on() 
  else 
    led.off()
  end
end 

return led

Using ChiliPeppr's Nodemcu Workspace to Edit Lua Code

I use the ChiliPeppr Nodemcu workspace at http://chilipeppr.com/nodemcu to edit all code and for uploading to the Nodemcu ESP8266 device. It's a really good workflow and I think much better than programming via the Arduino IDE because it fully embraces the flash memory on the ESP8266. This lets you develop different portions of code iteratively and just reference the module.

You can also send and receive Cayenn commands in the serial port console window to debug your code.