]> hydra-www.ietfng.org Git - acmetensortoys-esp-lua_thermostat/commitdiff
Initial checkin
authorNathaniel Wesley Filardo <nwf@cs.jhu.edu>
Tue, 1 Aug 2017 00:15:05 +0000 (20:15 -0400)
committerNathaniel Wesley Filardo <nwf@cs.jhu.edu>
Fri, 4 Aug 2017 20:12:05 +0000 (16:12 -0400)
README.rst [new file with mode: 0644]
init2.lua [new file with mode: 0644]
init3.lua [new file with mode: 0644]
pushall.sh [new file with mode: 0755]
thermostat.lua [new file with mode: 0644]

diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..c134a33
--- /dev/null
@@ -0,0 +1,130 @@
+Software
+########
+
+MQTT Topics
+===========
+
+See ``init3.lua`` and calls to ``logdata``, but in summary:
+
+* Topics set by the device:
+
+  * ``.../boot`` -- heartbeat, boot announcement, and LWT.
+  * ``.../th``   -- temperature probe result
+  * ``.../zz``   -- temporary debug topic
+
+* Topics set by the user/controller:
+
+  * ``.../fan``    -- ``on`` or ``1`` to force fan on, otherwise automatic.
+  * ``.../mode``   -- ``off``, ``cool``, ``heat``, or ``emht``
+  * ``.../target`` -- the target temperature, in half-degrees Celsius 
+
+.. note::
+
+   Temperatures are reported and consumed (in ``.../target``) in half
+   degrees Celsius.  (Funky, ain't it?)
+
+Control-side
+============
+
+I suggest a wrapper shell script, possibly named ``thermostat.sh`` or
+something, along these lines, filling in the ``...`` appropriately::
+
+   #!/bin/bash
+   mosquitto_pub -h ... -u ... -P ... -t ".../$1" -m "$2" "${@:3}"
+
+Then it's a matter of ``./thermostat.sh fan on``, ``./thermostat.sh mode
+cool``, or ``./thermostat.sh target 50``.
+
+Help!  My broker's down!  My network's down!
+============================================
+
+Don't panic!  If your network is still online, you can telnet in to the
+device.  Failing that, the serial console is still viable (best grab the
+pins with your own TTL adapter, as the nodemcu board has its own voltage
+regulator and will attempt to power the thermostat's 3.3V rail from USB,
+potentially fighting the other voltage regulator!).  Failing that, just put
+the original thermostat back, yeah?
+
+In any case, you can simulate the receipt of a MQTT message from the Lua
+interpreter prompt (or ``diag exec`` via ``telnetd``).  Try one of these::
+
+  nwfnet.onmqtt["th"](mqc, mqttTargTopic, "60" )
+  nwfnet.onmqtt["th"](mqc, mqttModeTopic, "off")
+  nwfnet.onmqtt["th"](mqc, mqttFanTopic , "on" )
+
+Hardware
+########
+
+Peripheral Setup
+================
+
++------+----+-----------------------------------------------------------+
+| GPIO | IX |                                                           | 
++======+====+===========================================================+
+|  16  |  0 | not used but somewhat special; "XPD"                      |
++------+----+-----------------------------------------------------------+
+|  5   |  1 | 1-Wire                                                    |
++------+----+-----------------------------------------------------------+
+|  4   |  2 | I2C SDA                                                   |
++------+----+-----------------------------------------------------------+
+|  0   |  3 | I2C SCL / pull 0 for bootloader / bounce low to stop init |
++------+----+-----------------------------------------------------------+
+|  2   |  4 | WS2812, by necessity of hardware                          |
++------+----+-----------------------------------------------------------+
+|  14  |  5 | not used, but reserved for PCF IRQ                        |
++------+----+-----------------------------------------------------------+
+|  12  |  6 | not used                                                  |
++------+----+-----------------------------------------------------------+
+|  13  |  7 | not used                                                  |
++------+----+-----------------------------------------------------------+
+|  15  |  8 | Pull low to select boot mode                              |
++------+----+-----------------------------------------------------------+
+
+.. note::
+
+   * GPIO2 (ix 4) is also the onboard LED
+   * GPIOs 1,3 (ixes 9,10) are used for serial UART
+   * GPIOs 6-11 (incl. 9,10, ixes 11,12) are used in chatting with the flash chip
+
+I2C Peripherals
+---------------
+
+We have a PCF8574A attached to us on the I2C bus at address 0x38.  Its IO
+lines are used as follows:
+
++----+-------------------+
+| P0 | Relay 1: Fan      |
++----+-------------------+
+| P1 | Relay 2: AC       |
++----+-------------------+
+| P2 | Relay 3: Heat     |
++----+-------------------+
+| P3 | Relay 4: Em Heat  |
++----+-------------------+
+| P4 |                   |
++----+-------------------+
+| P5 |                   |
++----+-------------------+
+| P6 |                   |
++----+-------------------+
+| P7 |                   |
++----+-------------------+
+
+1W Peripherals
+--------------
+
+We have a DS1820 temperature probe attached to the 1Wire bus.  This device
+calls itself 1013878a02080098 in my case.
+
+Internals
+=========
+
+RTC RAM Slots
+-------------
+
+* Slots 0  - 9   are used by the RTC itself
+* Slots 10 - 20  are used by the RTC fifo for metadata
+* Slots 21 - 31  are unused
+* Slots 32 - 128 are used by the RTC fifo for its journal
+
+
diff --git a/init2.lua b/init2.lua
new file mode 100644 (file)
index 0000000..06d1940
--- /dev/null
+++ b/init2.lua
@@ -0,0 +1,14 @@
+-- It's early in boot, so we have plenty of RAM.  Compile
+-- the rest of the firmware from source if it's there.
+dofile("compileall.lc")
+
+-- telnetd overlay
+tcpserv = net.createServer(net.TCP, 120)
+tcpserv:listen(23,function(k)
+  local telnetd = dofile "telnetd.lc"
+  telnetd.on["conn"] = function(k) k(string.format("%s [NODE-%06X]",mqcu,node.chipid())) end
+  telnetd.server(k)
+end)
+
+print("Startup")
+dofile("init3.lc")
diff --git a/init3.lua b/init3.lua
new file mode 100644 (file)
index 0000000..674cd2c
--- /dev/null
+++ b/init3.lua
@@ -0,0 +1,63 @@
+-- local configuration
+owpin = 1
+local mqttHeartTopic = "lcn/therm/boot"
+local mqttHeartTick  = 600000
+mqttTargTopic  = "lcn/therm/target"
+mqttModeTopic  = "lcn/therm/mode"
+mqttFanTopic   = "lcn/therm/fan"
+mqttPubRoot    = "lcn/therm/"
+
+-- modules
+nwfnet = require "nwfnet"
+mqc, mqcu = dofile("nwfmqtt.lc").mkclient("nwfmqtt.conf")
+
+mqcCan = false
+
+-- rtcfifo conditional init
+if rtcfifo.ready() == 0 then rtcfifo.prepare() end
+
+-- timers
+tq = (dofile "tq.lc")(tmr.create())
+
+-- setup peripherals
+ow.setup(owpin)
+i2c.setup(0,2,3,i2c.SLOW)
+
+-- hook registry, MQTT connection management
+local mqtt_beat_cancel
+local mqtt_reconn_poller
+local function mqtt_reconn()
+  mqtt_reconn_poller = tq:queue(30000,mqtt_reconn)
+  mqc:close(); dofile("nwfmqtt.lc").connect(mqc,"nwfmqtt.conf")
+end
+
+nwfnet.onnet["init"] = function(e,c)
+  if     e == "mqttdscn" and c == mqc then
+    if mqtt_beat_cancel then mqtt_beat_cancel(); mqtt_beat_cancel = nil end
+    if not mqtt_reconn_poller then mqtt_reconn() end
+    mqcCan = false
+  elseif e == "mqttconn" and c == mqc then
+    if mqtt_reconn_poller then tq:dequeue(mqtt_reconn_poller); mqtt_reconn_poller = nil end
+    if not mqtt_beat_cancel then mqtt_beat_cancel = dofile("nwfmqtt.lc").heartbeat(mqc,mqttHeartTopic,tq,mqttHeartTick) end
+    mqc:publish(mqttHeartTopic,"alive",1,1)
+    mqc:subscribe(mqttTargTopic,1)
+    mqc:subscribe(mqttModeTopic,1)
+    mqc:subscribe(mqttFanTopic ,1)
+    mqcCan = true
+  elseif e == "wstagoip"              then
+    if not mqtt_reconn_poller then mqtt_reconn() end
+  end
+end
+
+-- data logging
+function logdata(v,e,n)
+  local t = rtctime.get()
+  if mqcCan then mqc:publish(mqttPubRoot..n,sjson.encode({ ['t']=t, ['v']=v, ['e']=e }),1,1) end
+  if v then rtcfifo.put(t,v,e,n) end
+end
+
+-- go online
+dofile("nwfnet-go.lc")
+
+-- do thermostat stuff
+dofile("thermostat.lc")
diff --git a/pushall.sh b/pushall.sh
new file mode 100755 (executable)
index 0000000..3ac1181
--- /dev/null
@@ -0,0 +1,53 @@
+#!/bin/zsh
+
+set -e -u
+
+. ./core/host/pushcommon.sh
+
+pushsrc() {
+  dopushcompile core/util/compileall.lua
+  dopushlua     core/net/nwfmqtt.lua
+  dopushlua     core/util/ow-ds18b20.lua
+  dopushlua     core/util/i2cu.lua
+  dopushlua     thermostat.lua
+  dopushlua     init3.lua
+  dopushcompile init2.lua
+}
+
+if [ -n "${2:-}" ]; then
+  if [ -d $2 ]; then CONFDIR=$2
+  else echo "Not a directory: $2"; exit 1
+  fi
+fi
+
+pushconf() {
+  if [ -z "${CONFDIR:-}" ]; then
+    echo "Asked to push config without specifying?"
+    exit 1
+  fi
+  for f in ${CONFDIR}/*; do
+    dopushtext "$f"
+  done
+}
+
+case "${1:-}" in
+  all)
+    pushconf
+    pushsrc
+    ./core/host/pushinit.sh
+    ;;
+  both)
+    pushconf
+    pushsrc
+    ;;
+  src)
+    pushsrc
+    ;;
+  conf)
+    pushconf
+    ;;
+  *)
+    echo "Please specify push mode: {conf,src,both,all}"
+    exit 1
+    ;;
+esac
diff --git a/thermostat.lua b/thermostat.lua
new file mode 100644 (file)
index 0000000..d2112a8
--- /dev/null
@@ -0,0 +1,174 @@
+-- local configuration (TODO: pull in from json?)
+local owtherm = encoder.fromHex("1013878a02080098")
+local pcfaddr = 0x38
+local pcfhigh = 0xF0
+local tcPollIval = 9000   -- + 1 second for 1w read, too
+local tcVWin     = 10     -- longest sliding sampling window
+local tcFOffDly  = 180000 -- run the fan after turning of H/AC
+
+-- remote configuration (set by MQTT)
+local tctarget   = 55
+local tcmode     = "off" -- "off" "cool" "heat" "emht"
+local tcfan      = false -- should we keep the fan on?
+
+-- state
+local driving          = false -- are we driving?
+local fanOffDelayTQ    = nil   -- a tq object for nixing the fan
+local vdenom           = 0     -- window votes elapsed
+local vnum             = 0     -- window votes accumulated
+local verr             = 0     -- errors during voting window
+
+local function resetTempAcc()
+  verr  = 0
+  vdenom = 0
+  vnum = 0
+end
+
+local function mkRelays(mode, drive, forcefan)
+  local v = 0xF -- "off"
+
+  if drive then
+    if     mode == "cool" then v = 0xC
+    elseif mode == "heat" then v = 0xA
+    elseif mode == "emht" then v = 0x6
+    end
+  end 
+  if forcefan then v = bit.band(v, 0xE) end
+
+  return v
+end
+
+local i2cu = require "i2cu"
+
+function doRelays()
+  local v = mkRelays(tcmode, driving, tcfan)
+  i2cu.writen(pcfaddr, string.char(bit.bor(pcfhigh, v)))
+  return v -- XXX debug
+end
+
+nwfnet.onmqtt["th"] = function(c,t,m)
+  if not m then return end
+  if     t == mqttTargTopic then
+    driving = false; resetTempAcc()
+    tctarget = tonumber(m) or tctarget
+  elseif t == mqttModeTopic then
+    driving = false; resetTempAcc()
+    tcmode    = m
+  elseif t == mqttFanTopic  then
+    nextFan    = (m == "on" or m == "1")
+
+    if fanOffDelayTQ == nil then
+      -- we aren't about to automate the fan off, so go ahead and let the
+      -- setting have immediate effect
+      tcfan = nextFan
+    else
+      -- we are about to turn off the fan anyway; is that what we should do?
+      if nextFan then -- no, keep the fan on
+        tq:dequeue(fanOffDelayTQ)
+        fanOffDelayTQ = nil
+        tcfan = nextFan
+      -- else let the callback turn it off for us
+      end
+    end
+  else   return -- not for us?
+  end
+
+  doRelays()
+end
+
+local function startDrive()
+  driving = true
+  -- nix any future fan-off we might have had scheduled
+  if fanOffDelayTQ ~= nil then tq:dequeue(fanOffDelayTQ); fanOffDelayTQ = nil end
+end
+
+local function stopDrive()
+  driving = false
+  -- if we aren't forcing the fan on, schedule it to be turned off later
+  if not tcfan and fanOffDelayTQ == nil then
+    tcfan = true
+    fanOffDelayTQ = tq:queue(tcFOffDly, function()
+      fanOffDelayTQ = nil
+      tcfan = false
+      doRelays()
+    end)
+  end
+end
+
+local function therm_res(t)
+  logdata(t,0,"th")
+
+  local m
+  if driving then m = "S-on" else m = "S-off" end
+  -- no action needed, will have been set when mode set; we may have left
+  -- the polling loop active to continue to log data and all that
+  if     tcmode == "off" then m = "Off"
+  elseif tcmode == "fan" then m = "Fan"
+  else
+    -- OK, maybe we need to act
+
+    vdenom = vdenom + 1
+
+    -- push a little past the target in the direction indicated
+    local thresh = tctarget
+    if driving then
+      if     tcmode == "cool" then thresh = thresh - 2
+      elseif tcmode == "heat" then thresh = thresh + 2
+      end
+    end
+
+    -- Vote to engage or stay on
+    if     t == nil                            then verr  = verr  + 1
+    elseif tcmode == "cool" and t > thresh then vnum = vnum + 1
+    elseif tcmode == "heat" and t < thresh then vnum = vnum + 1
+    elseif tcmode == "emht" and t < thresh then vnum = vnum + 1
+    end
+
+    if     verr  >= 3 then
+      -- This window is definitely an error; shutdown now
+      m = "Error"
+      stopDrive()
+      resetTempAcc()
+    elseif vnum >= 7 then
+      -- This window is definitely voting for driving; start now
+      if driving
+       then m = "Keepon"
+       else m = "Drive"; startDrive()
+      end
+      resetTempAcc()
+    elseif vdenom >= tcVWin - 1 and vnum <= 2 then
+      -- This window is definitely voting to shut down
+      if driving
+        then m = "Cancel"; stopDrive()
+           else m = "Keepoff"
+      end
+      resetTempAcc()
+    elseif vdenom >= tcVWin then
+      -- This window has elapsed with no conclusion reached.
+      resetTempAcc()
+    end
+  end
+
+  local r = doRelays()
+
+  -- XXX debug
+  if mqcCan then
+    mqc:publish(mqttPubRoot.."zz",
+      sjson.encode({ ['m']=m, ['r']=r, ['h']=node.heap(),
+                     ['f']=tempAccFan, ['ftq']=(fanOffDelayTQ ~= nil),
+                     ['c']=vdenom, ['v']=vnum, ['e']=verr }),
+      1,1)
+  end
+
+end
+local function thermpoller()
+  dofile("ow-ds18b20.lc")(tq, owpin, owtherm, 0,
+    function(r)
+      therm_res(r)
+      therm_poll_cancel = tq:queue(tcPollIval,thermpoller)
+    end)
+end
+
+doRelays()    -- turn everything off at boot
+thermpoller() -- start polling